705 lines
23 KiB
Python
705 lines
23 KiB
Python
import asyncio
|
|
import io
|
|
import json
|
|
import os
|
|
import stat
|
|
import uuid
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from openai import APIError as OpenAIAPIError
|
|
|
|
from models.api_error_model import APIErrorModel
|
|
from models.image_prompt import ImagePrompt
|
|
from models.presentation_outline_model import PresentationOutlineModel, SlideOutlineModel
|
|
from models.presentation_structure_model import PresentationStructureModel
|
|
from models.sql.presentation import PresentationModel, PresentationVersion
|
|
from models.sql.slide import SlideModel
|
|
from models.sse_response import (
|
|
SSECompleteResponse,
|
|
SSEErrorResponse,
|
|
SSEStatusResponse,
|
|
SSETraceResponse,
|
|
SSEResponse,
|
|
)
|
|
from services.chat.conversation_store import ChatConversationStore
|
|
from services.concurrent_service import ConcurrentService
|
|
from services.export_task_service import EXPORT_TASK_SERVICE
|
|
from templates import get_layout_by_name as tpl_layout_fetcher
|
|
from templates.presentation_layout import PresentationLayoutModel, SlideLayoutModel
|
|
from utils import ocr_language
|
|
from utils.datetime_utils import get_current_utc_datetime
|
|
from utils.export_utils import export_presentation
|
|
from utils.file_utils import (
|
|
get_file_ext_or_none,
|
|
get_file_name_with_random_uuid,
|
|
get_original_file_name,
|
|
replace_file_name,
|
|
set_file_ext,
|
|
)
|
|
from utils.get_dynamic_models import (
|
|
get_presentation_outline_model_with_n_slides,
|
|
get_presentation_structure_model_with_n_slides,
|
|
)
|
|
from utils.image_provider import (
|
|
get_selected_image_provider,
|
|
is_comfyui_selected,
|
|
is_dalle3_selected,
|
|
is_gemini_flash_selected,
|
|
is_gpt_image_1_5_selected,
|
|
is_image_generation_disabled,
|
|
is_nanobanana_pro_selected,
|
|
is_open_webui_selected,
|
|
is_pixabay_selected,
|
|
is_pixels_selected,
|
|
)
|
|
from utils.llm_client_error_handler import handle_llm_client_exceptions
|
|
from utils.parsers import parse_bool_or_none
|
|
from utils.path_helpers import get_resource_path, get_writable_path
|
|
from utils.sse import safe_sse_stream
|
|
|
|
_ALL_IMAGE_PROVIDER_PREDICATES = (
|
|
is_pixels_selected,
|
|
is_pixabay_selected,
|
|
is_gemini_flash_selected,
|
|
is_nanobanana_pro_selected,
|
|
is_dalle3_selected,
|
|
is_gpt_image_1_5_selected,
|
|
is_comfyui_selected,
|
|
is_open_webui_selected,
|
|
)
|
|
|
|
|
|
def _parse_sse_frame(blob: str) -> tuple[str, dict]:
|
|
"""Parse a single SSEResponse / derivative frame (event + JSON data)."""
|
|
text = blob.strip()
|
|
lines = text.split("\n")
|
|
assert len(lines) == 2
|
|
assert lines[0].startswith("event: ")
|
|
assert lines[1].startswith("data: ")
|
|
event = lines[0].removeprefix("event: ")
|
|
raw_data = lines[1].removeprefix("data: ")
|
|
return event, json.loads(raw_data)
|
|
|
|
|
|
def _outline_layout_structure_payloads() -> tuple[dict, dict, dict]:
|
|
layout_payload = PresentationLayoutModel(
|
|
name="n",
|
|
ordered=True,
|
|
slides=[SlideLayoutModel(id="z", json_schema={"title": "t"})],
|
|
).model_dump(mode="json")
|
|
structure_payload = PresentationStructureModel(slides=[0]).model_dump(mode="json")
|
|
outline = PresentationOutlineModel(
|
|
slides=[SlideOutlineModel(content="## Hello")]
|
|
).model_dump(mode="json")
|
|
return outline, layout_payload, structure_payload
|
|
|
|
|
|
def test_get_current_utc_datetime_is_timezone_aware():
|
|
dt = get_current_utc_datetime()
|
|
assert dt.tzinfo is not None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("value", "expected"),
|
|
[
|
|
(None, None),
|
|
("true", True),
|
|
("TRUE", True),
|
|
("TrUe", True),
|
|
("false", False),
|
|
("FALSE", False),
|
|
("", False),
|
|
("yes", False),
|
|
("1", False),
|
|
("\tfalse\n", False),
|
|
],
|
|
)
|
|
def test_parse_bool_or_none(value: str | None, expected):
|
|
assert parse_bool_or_none(value) is expected
|
|
|
|
|
|
def test_image_prompt_with_theme_formats_prompt():
|
|
p = ImagePrompt(prompt="lake", theme_prompt="muted colors")
|
|
assert p.get_image_prompt(with_theme=False) == "lake"
|
|
assert p.get_image_prompt(with_theme=True) == "lake, muted colors"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("exc", "status", "substr"),
|
|
[
|
|
(HTTPException(status_code=418, detail="teapot"), 418, "teapot"),
|
|
(RuntimeError("boom"), 500, "boom"),
|
|
],
|
|
)
|
|
def test_api_error_model_from_exception(exc: Exception, status: int, substr: str):
|
|
model = APIErrorModel.from_exception(exc)
|
|
assert model.status_code == status
|
|
assert substr in model.detail
|
|
|
|
|
|
def test_slide_model_get_new_slide_branches():
|
|
pid = uuid.uuid4()
|
|
base = SlideModel(
|
|
presentation=pid,
|
|
layout_group="g",
|
|
layout="l",
|
|
index=2,
|
|
content={"a": 1},
|
|
html_content="<div>slide</div>",
|
|
speaker_note="n",
|
|
properties={"x": True},
|
|
ui={"id": "layout-1", "components": []},
|
|
)
|
|
assert base.get_new_slide(pid, None).content == {"a": 1}
|
|
assert base.get_new_slide(pid, {"b": 2}).content == {"b": 2}
|
|
assert base.get_new_slide(pid).ui == {"id": "layout-1", "components": []}
|
|
assert base.get_new_slide(pid).html_content == "<div>slide</div>"
|
|
|
|
|
|
def test_sse_response_frame_format():
|
|
raw = SSEResponse(event="evt", data="{}").to_string()
|
|
assert raw == "event: evt\ndata: {}\n\n"
|
|
|
|
|
|
def test_sse_typed_frames_encode_json_payloads():
|
|
event, data = _parse_sse_frame(SSEStatusResponse(status="ok").to_string())
|
|
assert event == "response"
|
|
assert data == {"type": "status", "status": "ok"}
|
|
|
|
event, data = _parse_sse_frame(SSETraceResponse(trace={"x": 1}).to_string())
|
|
assert event == "response"
|
|
assert data == {"type": "trace", "trace": {"x": 1}}
|
|
|
|
event, data = _parse_sse_frame(SSEErrorResponse(detail="bad").to_string())
|
|
assert event == "response"
|
|
assert data == {"type": "error", "detail": "bad"}
|
|
|
|
event, data = _parse_sse_frame(
|
|
SSECompleteResponse(key="k", value={"v": 1}).to_string()
|
|
)
|
|
assert event == "response"
|
|
assert data == {"type": "complete", "k": {"v": 1}}
|
|
|
|
|
|
def test_safe_sse_stream_converts_late_exception_to_error_frame():
|
|
async def broken_stream():
|
|
yield SSEResponse(
|
|
event="response",
|
|
data=json.dumps({"type": "chunk"}),
|
|
).to_string()
|
|
raise RuntimeError("provider failed")
|
|
|
|
async def collect():
|
|
chunks = []
|
|
async for chunk in safe_sse_stream(
|
|
broken_stream(),
|
|
logger=MagicMock(),
|
|
error_detail="Stream failed",
|
|
):
|
|
chunks.append(chunk)
|
|
return chunks
|
|
|
|
chunks = asyncio.run(collect())
|
|
assert len(chunks) == 2
|
|
event, data = _parse_sse_frame(chunks[-1])
|
|
assert event == "response"
|
|
assert data == {"type": "error", "detail": "Stream failed"}
|
|
|
|
|
|
@pytest.mark.parametrize("theme", [{}, None])
|
|
def test_presentation_model_get_new_and_typed_getters(theme):
|
|
outline, layout_payload, structure_payload = _outline_layout_structure_payloads()
|
|
p = PresentationModel(
|
|
id=uuid.uuid4(),
|
|
version=PresentationVersion.V1_STANDARD,
|
|
content="c",
|
|
n_slides=1,
|
|
language="English",
|
|
title="Title",
|
|
outlines=outline,
|
|
layout=layout_payload,
|
|
structure=structure_payload,
|
|
theme=theme,
|
|
fonts={"heading": "Inter"},
|
|
web_search=True,
|
|
)
|
|
new_presentation = p.get_new_presentation()
|
|
assert p.version == PresentationVersion.V1_STANDARD
|
|
assert new_presentation.version == PresentationVersion.V1_STANDARD
|
|
assert new_presentation.content == "c"
|
|
assert new_presentation.theme == theme
|
|
assert new_presentation.fonts == {"heading": "Inter"}
|
|
assert new_presentation.web_search is True
|
|
assert isinstance(p.get_presentation_outline(), PresentationOutlineModel)
|
|
assert isinstance(p.get_layout(), PresentationLayoutModel)
|
|
assert isinstance(p.get_structure(), PresentationStructureModel)
|
|
|
|
|
|
def test_presentation_model_set_layout_updates_stored_dict():
|
|
_, layout_payload, _ = _outline_layout_structure_payloads()
|
|
p = PresentationModel(
|
|
id=uuid.uuid4(),
|
|
version=PresentationVersion.V1_STANDARD,
|
|
content="c",
|
|
n_slides=1,
|
|
language="English",
|
|
title="Title",
|
|
outlines=None,
|
|
layout=layout_payload,
|
|
structure=None,
|
|
)
|
|
refreshed_layout = PresentationLayoutModel(
|
|
name="n3",
|
|
ordered=False,
|
|
slides=[SlideLayoutModel(id="q", json_schema={"title": "t3"})],
|
|
)
|
|
p.set_layout(refreshed_layout)
|
|
assert p.layout["name"] == "n3"
|
|
|
|
|
|
def test_presentation_model_missing_outline_and_structure_returns_none():
|
|
ghost = PresentationModel(
|
|
id=uuid.uuid4(),
|
|
version=PresentationVersion.V1_STANDARD,
|
|
content="c",
|
|
n_slides=0,
|
|
language="English",
|
|
outlines=None,
|
|
layout=None,
|
|
structure=None,
|
|
)
|
|
assert ghost.get_presentation_outline() is None
|
|
assert ghost.get_structure() is None
|
|
|
|
|
|
def test_presentation_model_set_structure_updates_slides():
|
|
_, _, structure_payload = _outline_layout_structure_payloads()
|
|
p = PresentationModel(
|
|
id=uuid.uuid4(),
|
|
version=PresentationVersion.V1_STANDARD,
|
|
content="c",
|
|
n_slides=1,
|
|
language="English",
|
|
title="Title",
|
|
outlines=None,
|
|
layout=None,
|
|
structure=structure_payload,
|
|
)
|
|
p.set_structure(PresentationStructureModel(slides=[0, 1]))
|
|
assert p.structure["slides"] == [0, 1]
|
|
|
|
|
|
def test_chat_conversation_store_ensure_conversation_id():
|
|
store = ChatConversationStore(sql_session=None) # type: ignore[arg-type]
|
|
nid = asyncio.run(store.ensure_conversation_id(None))
|
|
assert isinstance(nid, uuid.UUID)
|
|
cid = uuid.uuid4()
|
|
assert asyncio.run(store.ensure_conversation_id(cid)) == cid
|
|
|
|
|
|
def test_get_resource_path_resolves_under_cwd(monkeypatch, tmp_path):
|
|
monkeypatch.chdir(tmp_path)
|
|
resolved = get_resource_path("static/a.txt")
|
|
assert resolved == os.path.abspath(os.path.join(str(tmp_path), "static/a.txt"))
|
|
|
|
|
|
def test_get_writable_path_app_data_fallback(monkeypatch, tmp_path):
|
|
monkeypatch.chdir(tmp_path)
|
|
|
|
monkeypatch.delenv("APP_DATA_DIRECTORY", raising=False)
|
|
base = tmp_path / "appdata"
|
|
base.mkdir()
|
|
monkeypatch.setenv("APP_DATA_DIRECTORY", str(base))
|
|
ad_path = get_writable_path("nested/dir")
|
|
assert os.path.abspath(ad_path).startswith(os.path.abspath(str(base)))
|
|
|
|
monkeypatch.delenv("APP_DATA_DIRECTORY", raising=False)
|
|
cwd_path = get_writable_path("other/dir")
|
|
assert cwd_path.startswith(os.path.abspath(str(tmp_path)))
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("env_provider", "predicate"),
|
|
[
|
|
("pexels", is_pixels_selected),
|
|
("pixabay", is_pixabay_selected),
|
|
("gemini_flash", is_gemini_flash_selected),
|
|
("nanobanana_pro", is_nanobanana_pro_selected),
|
|
("dall-e-3", is_dalle3_selected),
|
|
("gpt-image-1.5", is_gpt_image_1_5_selected),
|
|
("comfyui", is_comfyui_selected),
|
|
("open_webui", is_open_webui_selected),
|
|
],
|
|
)
|
|
def test_image_provider_env_flags_exclusive(monkeypatch, env_provider: str, predicate):
|
|
monkeypatch.setenv("IMAGE_PROVIDER", env_provider)
|
|
assert predicate() is True
|
|
for other in _ALL_IMAGE_PROVIDER_PREDICATES:
|
|
if other is not predicate:
|
|
assert other() is False
|
|
|
|
|
|
def test_get_selected_image_provider_none(monkeypatch):
|
|
monkeypatch.delenv("IMAGE_PROVIDER", raising=False)
|
|
assert get_selected_image_provider() is None
|
|
|
|
|
|
def test_get_selected_image_provider_invalid_env_raises(monkeypatch):
|
|
monkeypatch.setenv("IMAGE_PROVIDER", "not-a-real-provider")
|
|
with pytest.raises(ValueError):
|
|
get_selected_image_provider()
|
|
|
|
|
|
def test_dynamic_outline_and_structure_factories_validate():
|
|
long_text = "x" * 100
|
|
OutlineCls = get_presentation_outline_model_with_n_slides(1)
|
|
outline = OutlineCls(slides=[{"content": long_text}])
|
|
|
|
StructureCls = get_presentation_structure_model_with_n_slides(3)
|
|
structure = StructureCls(slides=[0, 1, 2])
|
|
|
|
assert outline.slides[0].content == long_text
|
|
assert structure.slides == [0, 1, 2]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("language", "code"),
|
|
[
|
|
("English", "eng"),
|
|
("english", "eng"),
|
|
(None, "eng"),
|
|
(" ", "eng"),
|
|
("Hausa (Hausa)", "hau"),
|
|
],
|
|
)
|
|
def test_presentation_language_to_ocr_resolution(language: str | None, code: str):
|
|
assert ocr_language.presentation_language_to_ocr_code(language) == code
|
|
|
|
|
|
def test_presentation_language_invalid_code_fallback(monkeypatch):
|
|
monkeypatch.setitem(
|
|
ocr_language.PRESENTATION_LANGUAGE_TO_TESSERACT,
|
|
"__bad_lang__",
|
|
"not valid!",
|
|
)
|
|
assert ocr_language.presentation_language_to_ocr_code("__bad_lang__") == "eng"
|
|
|
|
|
|
def test_handle_llm_client_exceptions(monkeypatch):
|
|
monkeypatch.setattr(
|
|
handle_llm_client_exceptions.__globals__["traceback"],
|
|
"print_exc",
|
|
lambda: None,
|
|
)
|
|
assert (
|
|
handle_llm_client_exceptions(HTTPException(status_code=401, detail="auth")).detail
|
|
== "auth"
|
|
)
|
|
|
|
from google.genai.errors import APIError as GoogleAPIError
|
|
from llmai.shared.errors import BaseError as LLMAIBaseError
|
|
from utils.provider_error_messages import INVALID_API_KEY_MESSAGE
|
|
|
|
llmai_err = LLMAIBaseError(status_code=429, message="busy")
|
|
assert handle_llm_client_exceptions(llmai_err).detail == "busy"
|
|
|
|
wrapped_auth_err = LLMAIBaseError(
|
|
status_code=401,
|
|
message=(
|
|
"Error code: 401 - {'error': {'message': "
|
|
"'Incorrect API key provided: sk-proj-secret', "
|
|
"'code': 'invalid_api_key'}}"
|
|
),
|
|
)
|
|
wrapped_auth_response = handle_llm_client_exceptions(wrapped_auth_err)
|
|
assert wrapped_auth_response.detail == INVALID_API_KEY_MESSAGE
|
|
assert "sk-proj" not in wrapped_auth_response.detail
|
|
|
|
assert "OpenAI API request failed" in handle_llm_client_exceptions(
|
|
OpenAIAPIError(
|
|
message="boom",
|
|
request=httpx.Request("POST", "https://x"),
|
|
body=None,
|
|
)
|
|
).detail
|
|
|
|
assert "Google API error" in handle_llm_client_exceptions(
|
|
GoogleAPIError(503, {})
|
|
).detail
|
|
|
|
from enums.llm_provider import LLMProvider
|
|
|
|
monkeypatch.setitem(
|
|
handle_llm_client_exceptions.__globals__,
|
|
"get_llm_provider",
|
|
lambda: LLMProvider.CODEX,
|
|
)
|
|
codex_auth = handle_llm_client_exceptions(
|
|
HTTPException(status_code=401, detail="expired")
|
|
)
|
|
assert codex_auth.status_code == 401
|
|
assert "CHATGPT_AUTH_REQUIRED:" in codex_auth.detail
|
|
assert codex_auth.headers == {"X-Presenton-Auth-Action": "codex-reauth"}
|
|
|
|
generic = handle_llm_client_exceptions(ValueError("oops"))
|
|
assert generic.detail.startswith("LLM API error")
|
|
|
|
|
|
def test_concurrent_service_runs_tasks(monkeypatch):
|
|
mock_sleep = AsyncMock()
|
|
monkeypatch.setattr(asyncio, "sleep", mock_sleep)
|
|
|
|
svc = ConcurrentService()
|
|
touched = []
|
|
|
|
async def work():
|
|
touched.append(1)
|
|
|
|
async def runner():
|
|
svc.run_task(None, work)
|
|
svc.run_task(3, work)
|
|
await asyncio.gather(*list(svc._background_tasks))
|
|
|
|
asyncio.run(runner())
|
|
assert len(touched) == 2
|
|
mock_sleep.assert_awaited_once_with(3)
|
|
|
|
|
|
def test_export_includes_optional_fastapi_param():
|
|
async def runner():
|
|
fake_result = MagicMock(path="/exports/deck.pdf")
|
|
dummy = uuid.uuid4()
|
|
mock_pdf = AsyncMock(return_value=fake_result)
|
|
with patch.dict(
|
|
os.environ,
|
|
{
|
|
"NEXT_PUBLIC_URL": "https://next.example",
|
|
"NEXT_PUBLIC_FAST_API": "https://fast.example",
|
|
},
|
|
clear=False,
|
|
), patch.object(EXPORT_TASK_SERVICE, "export_from_url", mock_pdf):
|
|
await export_presentation(
|
|
dummy,
|
|
title="safe",
|
|
export_as="pdf",
|
|
cookie_header="presenton_session=abc; theme=dark",
|
|
)
|
|
|
|
pdf_call = mock_pdf.await_args.kwargs
|
|
assert "pdf-maker" in pdf_call["url"]
|
|
assert (
|
|
"#exportCookie=presenton_session%3Dabc%3B+theme%3Ddark"
|
|
in pdf_call["url"]
|
|
)
|
|
assert pdf_call["fastapi_url"] == "https://fast.example"
|
|
assert pdf_call["cookie_header"] == "presenton_session=abc; theme=dark"
|
|
|
|
mock_pptx = AsyncMock(return_value=fake_result)
|
|
with patch.dict(
|
|
os.environ, {"NEXT_PUBLIC_FAST_API": ""}, clear=False
|
|
), patch.object(EXPORT_TASK_SERVICE, "export_from_url", mock_pptx):
|
|
await export_presentation(dummy, title="two", export_as="pptx")
|
|
pptx_call = mock_pptx.await_args.kwargs
|
|
assert "#" not in pptx_call["url"]
|
|
assert pptx_call["fastapi_url"] is None
|
|
|
|
asyncio.run(runner())
|
|
|
|
|
|
def test_export_task_output_permissions_are_readable(tmp_path):
|
|
export_dir = tmp_path / "exports"
|
|
export_dir.mkdir(mode=0o700)
|
|
output_path = export_dir / "deck.pptx"
|
|
output_path.write_bytes(b"pptx")
|
|
os.chmod(export_dir, 0o700)
|
|
os.chmod(output_path, 0o600)
|
|
|
|
EXPORT_TASK_SERVICE._ensure_output_readable(str(output_path))
|
|
|
|
assert stat.S_IMODE(export_dir.stat().st_mode) == 0o755
|
|
assert stat.S_IMODE(output_path.stat().st_mode) == 0o644
|
|
|
|
|
|
def test_replace_and_extension_helpers():
|
|
assert replace_file_name("deck.pptx", "outline") == "outline.pptx"
|
|
assert replace_file_name("readme", "out") == "out"
|
|
replaced = replace_file_name("note.txt", "fixed")
|
|
assert replaced == "fixed.txt"
|
|
randomized = replace_file_name("note.txt", f"note----{uuid.uuid4()}")
|
|
assert randomized.endswith(".txt") and "----" in randomized
|
|
assert (
|
|
get_original_file_name(os.path.join("ignored", randomized)) == "note.txt"
|
|
)
|
|
|
|
|
|
def test_get_file_ext_or_none():
|
|
assert get_file_ext_or_none("photo.PNG") == ".PNG"
|
|
assert get_file_ext_or_none("readme") == ""
|
|
|
|
|
|
def test_get_file_ext_or_none_truncated_extension_tuple(monkeypatch):
|
|
monkeypatch.setattr(os.path, "splitext", lambda _: ("base",))
|
|
assert get_file_ext_or_none("base") is None
|
|
|
|
|
|
def test_set_file_ext_monkey_patch():
|
|
assert set_file_ext("/tmp/with.txt", ".md").endswith(".md")
|
|
assert set_file_ext("/tmp/plain", ".md").endswith(".md")
|
|
|
|
|
|
def test_get_file_name_with_random_uuid_variants():
|
|
from starlette.datastructures import UploadFile as StarletteUploadFile
|
|
|
|
upload_like = StarletteUploadFile(filename="slide.png", file=io.BytesIO(b"x"))
|
|
out_upload = get_file_name_with_random_uuid(upload_like)
|
|
assert out_upload.endswith(".png") and "----" in out_upload
|
|
|
|
disk_path_out = get_file_name_with_random_uuid("/tmp/report.pdf")
|
|
assert disk_path_out.endswith(".pdf") and "----" in disk_path_out
|
|
|
|
assert "----" in get_file_name_with_random_uuid(io.BytesIO(b"z"))
|
|
|
|
|
|
def test_presentation_layout_model_surface():
|
|
layout = PresentationLayoutModel(
|
|
name="demo",
|
|
slides=[
|
|
SlideLayoutModel(id="sid", json_schema={"title": "From schema"}, description="d"),
|
|
],
|
|
)
|
|
assert "From schema" in layout.to_string()
|
|
with_schema = layout.to_string(with_schema=True)
|
|
assert '"title": "From schema"' in with_schema
|
|
assert layout.to_presentation_structure().slides == [0]
|
|
assert layout.get_slide_layout_index("sid") == 0
|
|
with pytest.raises(HTTPException):
|
|
layout.get_slide_layout_index("missing")
|
|
|
|
|
|
def _make_aio_layout_session(resp: AsyncMock):
|
|
sess = MagicMock()
|
|
getter = MagicMock()
|
|
getter.__aenter__ = AsyncMock(return_value=resp)
|
|
getter.__aexit__ = AsyncMock(return_value=None)
|
|
sess.get = MagicMock(return_value=getter)
|
|
sess.__aenter__ = AsyncMock(return_value=sess)
|
|
sess.__aexit__ = AsyncMock(return_value=None)
|
|
return sess
|
|
|
|
|
|
def test_get_layout_by_name_returns_model():
|
|
resp = AsyncMock()
|
|
resp.status = 200
|
|
resp.json = AsyncMock(
|
|
return_value={
|
|
"name": "grp",
|
|
"ordered": False,
|
|
"slides": [{"id": "layout", "json_schema": {"title": "t"}}],
|
|
}
|
|
)
|
|
|
|
async def runner():
|
|
with patch(
|
|
"utils.internal_http.get_configured_auth_username",
|
|
return_value="",
|
|
), patch(
|
|
"utils.internal_http.create_session_token",
|
|
return_value="cookie",
|
|
), patch(
|
|
"templates.get_layout_by_name.aiohttp.ClientSession",
|
|
return_value=_make_aio_layout_session(resp),
|
|
):
|
|
layout = await tpl_layout_fetcher.get_layout_by_name("deck")
|
|
assert isinstance(layout, PresentationLayoutModel)
|
|
|
|
asyncio.run(runner())
|
|
|
|
|
|
def test_get_layout_by_name_raises_on_http_failure():
|
|
resp = AsyncMock()
|
|
resp.status = 500
|
|
resp.text = AsyncMock(return_value="down")
|
|
|
|
async def runner():
|
|
with patch(
|
|
"utils.internal_http.get_configured_auth_username",
|
|
return_value="",
|
|
), patch(
|
|
"templates.get_layout_by_name.aiohttp.ClientSession",
|
|
return_value=_make_aio_layout_session(resp),
|
|
):
|
|
with pytest.raises(HTTPException):
|
|
await tpl_layout_fetcher.get_layout_by_name("missing")
|
|
|
|
asyncio.run(runner())
|
|
|
|
|
|
def test_get_layout_by_name_attach_auth_cookie(monkeypatch):
|
|
resp = AsyncMock()
|
|
resp.status = 200
|
|
resp.json = AsyncMock(
|
|
return_value={
|
|
"name": "grp",
|
|
"ordered": False,
|
|
"slides": [{"id": "layout", "json_schema": {"title": "t"}}],
|
|
}
|
|
)
|
|
|
|
captured: dict[str, str | None] = {}
|
|
|
|
monkeypatch.setattr(
|
|
"utils.internal_http.get_configured_auth_username", lambda: "user"
|
|
)
|
|
monkeypatch.setattr(
|
|
"utils.internal_http.create_session_token", lambda _u: "tok123"
|
|
)
|
|
monkeypatch.setattr("utils.internal_http.SESSION_COOKIE_NAME", "sess")
|
|
|
|
def capture_session(*_a, **_k):
|
|
sess = MagicMock()
|
|
|
|
def _get(url, *, headers=None, **_kw):
|
|
captured.update(headers or {})
|
|
inner = MagicMock()
|
|
inner.__aenter__ = AsyncMock(return_value=resp)
|
|
inner.__aexit__ = AsyncMock(return_value=None)
|
|
return inner
|
|
|
|
sess.get = MagicMock(side_effect=_get)
|
|
sess.__aenter__ = AsyncMock(return_value=sess)
|
|
sess.__aexit__ = AsyncMock(return_value=None)
|
|
return sess
|
|
|
|
async def runner():
|
|
with patch("templates.get_layout_by_name.aiohttp.ClientSession", side_effect=capture_session):
|
|
layout = await tpl_layout_fetcher.get_layout_by_name("deck")
|
|
assert isinstance(layout, PresentationLayoutModel)
|
|
|
|
asyncio.run(runner())
|
|
cookie = captured.get("Cookie", "")
|
|
assert "sess=tok123" in cookie
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
["true", "TRUE", "TrUe"],
|
|
)
|
|
def test_is_image_generation_disabled_truthy(monkeypatch, raw: str):
|
|
monkeypatch.setenv("DISABLE_IMAGE_GENERATION", raw)
|
|
assert is_image_generation_disabled() is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
[None, "", "false", "0", "maybe"],
|
|
)
|
|
def test_is_image_generation_disabled_falsey(monkeypatch, raw: str | None):
|
|
if raw is None:
|
|
monkeypatch.delenv("DISABLE_IMAGE_GENERATION", raising=False)
|
|
else:
|
|
monkeypatch.setenv("DISABLE_IMAGE_GENERATION", raw)
|
|
assert is_image_generation_disabled() is False
|