981 lines
32 KiB
Python
981 lines
32 KiB
Python
import asyncio
|
|
import uuid
|
|
from copy import deepcopy
|
|
from datetime import datetime, timezone
|
|
from unittest.mock import AsyncMock, Mock, patch
|
|
|
|
import pytest
|
|
from fastapi import BackgroundTasks, HTTPException
|
|
|
|
from api.v1.ppt.endpoints.templates import (
|
|
CreateTemplateV2LayoutsRequest,
|
|
CreateTemplateV2Request,
|
|
GenerateTemplateV2BlocksRequest,
|
|
InitTemplateV2Request,
|
|
PatchTemplateV2SlideLayoutRequest,
|
|
UpdateTemplateV2MetadataRequest,
|
|
_create_template_v2_sync,
|
|
_run_create_template_v2_task,
|
|
create_template_v2_slide_layouts,
|
|
create_template_v2,
|
|
delete_template_v2,
|
|
generate_template_v2_blocks,
|
|
get_template_v2,
|
|
init_template_v2,
|
|
list_templates_v2,
|
|
patch_template_v2_slide_layout,
|
|
update_template_v2_metadata,
|
|
)
|
|
from models.sql.async_task import AsyncTaskModel
|
|
from models.sql.template_v2 import TemplateV2
|
|
from services.export_task_service import PptxToJsonDocument
|
|
from templates.v2.models.layouts import MergedComponents, SlideLayouts
|
|
|
|
|
|
RAW_LAYOUTS = {
|
|
"layouts": [
|
|
{
|
|
"id": "slide_1",
|
|
"description": "Full slide layout converted from PPTX slide 1.",
|
|
"elements": [
|
|
{
|
|
"type": "rectangle",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 1280, "height": 720},
|
|
"fill": {"color": "#FFFFFF"},
|
|
},
|
|
{
|
|
"type": "image",
|
|
"position": {"x": 100, "y": 120},
|
|
"size": {"width": 320, "height": 180},
|
|
"data": "/app_data/pptx-to-json/session/images/photo.png",
|
|
"decorative": True,
|
|
"name": "photo",
|
|
"is_icon": False,
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
|
|
TEMPLATE_LAYOUTS = {
|
|
"layouts": [
|
|
{
|
|
"id": "slide_1",
|
|
"description": "Full slide layout converted from PPTX slide 1.",
|
|
"components": [
|
|
{
|
|
"id": "photo_component",
|
|
"description": "Reusable image component.",
|
|
"position": {"x": 100, "y": 120},
|
|
"size": {"width": 320, "height": 180},
|
|
"elements": [
|
|
{
|
|
"type": "image",
|
|
"position": {"x": 0, "y": 0},
|
|
"size": {"width": 320, "height": 180},
|
|
"data": "/app_data/pptx-to-json/session/images/photo.png",
|
|
"decorative": True,
|
|
"name": "photo",
|
|
"is_icon": False,
|
|
}
|
|
],
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
|
|
GENERATED_LAYOUTS = SlideLayouts.model_validate(TEMPLATE_LAYOUTS)
|
|
MERGED_COMPONENTS = MergedComponents.model_validate(
|
|
{
|
|
"components": [
|
|
{
|
|
"id": "photo_component",
|
|
"description": "Reusable image component.",
|
|
"variants": TEMPLATE_LAYOUTS["layouts"][0]["components"],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
|
|
def _two_raw_layouts():
|
|
return {
|
|
"layouts": [
|
|
RAW_LAYOUTS["layouts"][0],
|
|
{
|
|
**RAW_LAYOUTS["layouts"][0],
|
|
"id": "slide_2",
|
|
"description": "Full slide layout converted from PPTX slide 2.",
|
|
},
|
|
]
|
|
}
|
|
|
|
|
|
def _two_template_layouts():
|
|
return {
|
|
"layouts": [
|
|
TEMPLATE_LAYOUTS["layouts"][0],
|
|
{
|
|
**TEMPLATE_LAYOUTS["layouts"][0],
|
|
"id": "slide_2",
|
|
"description": "Full slide layout converted from PPTX slide 2.",
|
|
},
|
|
]
|
|
}
|
|
|
|
|
|
class _RowsResult:
|
|
def __init__(self, rows):
|
|
self._rows = rows
|
|
|
|
def all(self):
|
|
return self._rows
|
|
|
|
|
|
class _ListSession:
|
|
async def scalar(self, *_args, **_kwargs):
|
|
return 1
|
|
|
|
async def execute(self, *_args, **_kwargs):
|
|
now = datetime(2026, 6, 8, tzinfo=timezone.utc)
|
|
return _RowsResult(
|
|
[
|
|
(
|
|
"00000000-0000-0000-0000-000000000000",
|
|
"Generating Template",
|
|
"Still generating",
|
|
{"layouts": []},
|
|
{},
|
|
False,
|
|
now,
|
|
now,
|
|
),
|
|
(
|
|
"00000000-0000-0000-0000-000000000001",
|
|
"Quarterly Review",
|
|
"Board deck template",
|
|
TEMPLATE_LAYOUTS,
|
|
{
|
|
"slide_image_urls": [
|
|
"/app_data/images/slide-1.png",
|
|
"/app_data/images/slide-2.png",
|
|
]
|
|
},
|
|
False,
|
|
now,
|
|
now,
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
class _TemplateTaskSession:
|
|
def __init__(self, task: AsyncTaskModel):
|
|
self.task = task
|
|
self.added = []
|
|
self.commit_snapshots = []
|
|
|
|
async def get(self, _model, key):
|
|
return self.task if key == self.task.id else None
|
|
|
|
def add(self, obj):
|
|
self.added.append(obj)
|
|
|
|
async def commit(self):
|
|
self.commit_snapshots.append(
|
|
{
|
|
"status": self.task.status,
|
|
"message": self.task.message,
|
|
"data": deepcopy(self.task.data),
|
|
"error": deepcopy(self.task.error),
|
|
}
|
|
)
|
|
|
|
async def refresh(self, _obj):
|
|
return None
|
|
|
|
|
|
class _TemplateTaskSessionContext:
|
|
def __init__(self, session: _TemplateTaskSession):
|
|
self.session = session
|
|
|
|
async def __aenter__(self):
|
|
return self.session
|
|
|
|
async def __aexit__(self, *_args):
|
|
return False
|
|
|
|
|
|
def test_create_template_v2_converts_generates_and_persists(tmp_path, fake_async_session):
|
|
pptx_path = tmp_path / "quarterly-review.pptx"
|
|
pptx_path.write_bytes(b"pptx")
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.resolve_app_path_to_filesystem",
|
|
return_value=str(pptx_path),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.EXPORT_TASK_SERVICE.convert_pptx_to_json",
|
|
new=AsyncMock(return_value=PptxToJsonDocument(**RAW_LAYOUTS)),
|
|
) as convert_mock, patch(
|
|
"api.v1.ppt.endpoints.templates.generate_template",
|
|
new=Mock(return_value=GENERATED_LAYOUTS),
|
|
) as generate_mock, patch(
|
|
"api.v1.ppt.endpoints.templates.merge_similar_components",
|
|
new=Mock(return_value=MERGED_COMPONENTS),
|
|
) as merge_mock, patch(
|
|
"api.v1.ppt.endpoints.templates.random.randint",
|
|
return_value=4801,
|
|
) as randint_mock:
|
|
template = asyncio.run(
|
|
_create_template_v2_sync(
|
|
CreateTemplateV2Request(
|
|
pptx_url="/app_data/uploads/quarterly-review.pptx",
|
|
slide_image_urls=["/app_data/images/slide-1.png"],
|
|
fonts={"Inter": "Inter"},
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
convert_mock.assert_awaited_once_with(str(pptx_path))
|
|
generate_mock.assert_called_once()
|
|
raw_layouts_arg, slide_images_arg, fonts_arg = generate_mock.call_args.args
|
|
assert len(raw_layouts_arg.layouts) == 1
|
|
assert slide_images_arg == ["/app_data/images/slide-1.png"]
|
|
assert fonts_arg == {"Inter": "Inter"}
|
|
randint_mock.assert_called_once_with(1000, 9999)
|
|
merge_mock.assert_called_once()
|
|
merged_layouts_arg = merge_mock.call_args.args[0]
|
|
assert merged_layouts_arg.layouts[0].id == "slide_1_4801"
|
|
assert template.name == "quarterly-review"
|
|
assert template.raw_layouts == RAW_LAYOUTS
|
|
assert template.components is None
|
|
assert template.merged_components == MERGED_COMPONENTS.model_dump(
|
|
mode="json", exclude_none=True
|
|
)
|
|
assert GENERATED_LAYOUTS.layouts[0].id == "slide_1"
|
|
expected_layouts = {
|
|
"layouts": [
|
|
{
|
|
**TEMPLATE_LAYOUTS["layouts"][0],
|
|
"id": "slide_1_4801",
|
|
}
|
|
]
|
|
}
|
|
assert template.layouts == expected_layouts
|
|
assert template.assets == {
|
|
"fonts": {"Inter": "Inter"},
|
|
"slide_image_urls": ["/app_data/images/slide-1.png"],
|
|
"images": ["/app_data/pptx-to-json/session/images/photo.png"],
|
|
}
|
|
assert fake_async_session.added == [template]
|
|
assert fake_async_session.commit_count == 1
|
|
|
|
|
|
def test_create_template_v2_caps_raw_layouts_to_preview_images(tmp_path, fake_async_session):
|
|
pptx_path = tmp_path / "slidesgo.pptx"
|
|
pptx_path.write_bytes(b"pptx")
|
|
raw_layouts = {
|
|
"layouts": [
|
|
RAW_LAYOUTS["layouts"][0],
|
|
{
|
|
**RAW_LAYOUTS["layouts"][0],
|
|
"id": "slide_2",
|
|
"description": "Full slide layout converted from PPTX slide 2.",
|
|
},
|
|
]
|
|
}
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.resolve_app_path_to_filesystem",
|
|
return_value=str(pptx_path),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.EXPORT_TASK_SERVICE.convert_pptx_to_json",
|
|
new=AsyncMock(return_value=PptxToJsonDocument(**raw_layouts)),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.generate_template",
|
|
new=Mock(return_value=GENERATED_LAYOUTS),
|
|
) as generate_mock, patch(
|
|
"api.v1.ppt.endpoints.templates.merge_similar_components",
|
|
new=Mock(return_value=MERGED_COMPONENTS),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.random.randint",
|
|
return_value=4801,
|
|
):
|
|
template = asyncio.run(
|
|
_create_template_v2_sync(
|
|
CreateTemplateV2Request(
|
|
pptx_url="/app_data/uploads/slidesgo.pptx",
|
|
slide_image_urls=["/app_data/images/slide-1.png"],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
raw_layouts_arg = generate_mock.call_args.args[0]
|
|
assert [layout.id for layout in raw_layouts_arg.layouts] == ["slide_1"]
|
|
assert template.raw_layouts == RAW_LAYOUTS
|
|
assert template.assets["slide_image_urls"] == ["/app_data/images/slide-1.png"]
|
|
|
|
|
|
def test_create_template_v2_persists_when_component_dedup_fails(
|
|
tmp_path,
|
|
fake_async_session,
|
|
):
|
|
pptx_path = tmp_path / "dedup-fails.pptx"
|
|
pptx_path.write_bytes(b"pptx")
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.resolve_app_path_to_filesystem",
|
|
return_value=str(pptx_path),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.EXPORT_TASK_SERVICE.convert_pptx_to_json",
|
|
new=AsyncMock(return_value=PptxToJsonDocument(**RAW_LAYOUTS)),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.generate_template",
|
|
new=Mock(return_value=GENERATED_LAYOUTS),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.merge_similar_components",
|
|
new=Mock(side_effect=ValueError("bad clusters")),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.random.randint",
|
|
return_value=4801,
|
|
):
|
|
template = asyncio.run(
|
|
_create_template_v2_sync(
|
|
CreateTemplateV2Request(
|
|
pptx_url="/app_data/uploads/dedup-fails.pptx",
|
|
slide_image_urls=["/app_data/images/slide-1.png"],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert template.merged_components == {"components": []}
|
|
assert template.layouts["layouts"][0]["id"] == "slide_1_4801"
|
|
assert fake_async_session.commit_count == 1
|
|
|
|
|
|
def test_create_template_v2_requires_slide_images(fake_async_session):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
_create_template_v2_sync(
|
|
CreateTemplateV2Request(
|
|
pptx_url="/app_data/uploads/template.pptx",
|
|
slide_image_urls=[],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert exc.value.detail == "At least one slide image is required"
|
|
|
|
|
|
def test_create_template_v2_async_enqueues_task(fake_async_session):
|
|
background_tasks = BackgroundTasks()
|
|
|
|
task = asyncio.run(
|
|
create_template_v2(
|
|
background_tasks=background_tasks,
|
|
request=CreateTemplateV2Request(
|
|
pptx_url="/app_data/uploads/template.pptx",
|
|
slide_image_urls=["/app_data/images/slide-1.png"],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert task.type == "template.create"
|
|
assert task.status == "pending"
|
|
assert task.message == "Queued for template creation"
|
|
assert task.data == {
|
|
"created_layouts": 0,
|
|
"remaining_layouts": 1,
|
|
"slide_layout_statuses": [{"index": 0, "status": "pending"}],
|
|
"name": "template",
|
|
"thumbnail": "/app_data/images/slide-1.png",
|
|
}
|
|
assert fake_async_session.added == [task]
|
|
assert fake_async_session.commit_count == 1
|
|
assert len(background_tasks.tasks) == 1
|
|
|
|
|
|
def test_create_template_v2_async_task_updates_slide_status_before_batch_completes(
|
|
tmp_path,
|
|
):
|
|
pptx_path = tmp_path / "template.pptx"
|
|
pptx_path.write_bytes(b"pptx")
|
|
task = AsyncTaskModel(
|
|
id="task-template-create",
|
|
type="template.create",
|
|
status="pending",
|
|
)
|
|
session = _TemplateTaskSession(task)
|
|
generated_layouts = _two_template_layouts()["layouts"]
|
|
generation_max_tokens = []
|
|
|
|
def fake_generate_slide_layout(
|
|
_raw_layout, index, _slide_image_url, _fonts, *, max_tokens=None
|
|
):
|
|
generation_max_tokens.append(max_tokens)
|
|
return generated_layouts[index]
|
|
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.async_session_maker",
|
|
new=lambda: _TemplateTaskSessionContext(session),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.resolve_app_path_to_filesystem",
|
|
return_value=str(pptx_path),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.EXPORT_TASK_SERVICE.convert_pptx_to_json",
|
|
new=AsyncMock(return_value=PptxToJsonDocument(**_two_raw_layouts())),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.generate_slide_layout",
|
|
new=Mock(side_effect=fake_generate_slide_layout),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.merge_similar_components",
|
|
new=Mock(return_value=MERGED_COMPONENTS),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.random.randint",
|
|
side_effect=[4801, 4802],
|
|
):
|
|
asyncio.run(
|
|
_run_create_template_v2_task(
|
|
task.id,
|
|
CreateTemplateV2Request(
|
|
pptx_url="/app_data/uploads/template.pptx",
|
|
slide_image_urls=[
|
|
"/app_data/images/slide-1.png",
|
|
"/app_data/images/slide-2.png",
|
|
],
|
|
),
|
|
)
|
|
)
|
|
|
|
in_progress_slide_snapshot = next(
|
|
snapshot
|
|
for snapshot in session.commit_snapshots
|
|
if snapshot["status"] == "processing"
|
|
and snapshot["data"]
|
|
and snapshot["data"]["created_layouts"] == 1
|
|
)
|
|
slide_statuses = in_progress_slide_snapshot["data"]["slide_layout_statuses"]
|
|
assert in_progress_slide_snapshot["message"] == "Generating slide layouts"
|
|
assert in_progress_slide_snapshot["data"]["remaining_layouts"] == 1
|
|
assert sum(status["status"] == "completed" for status in slide_statuses) == 1
|
|
assert sum(status["status"] == "pending" for status in slide_statuses) == 1
|
|
|
|
assert task.status == "completed"
|
|
assert task.message == "Template creation completed"
|
|
assert generation_max_tokens == [16000, 16000]
|
|
assert task.data["slide_layout_statuses"] == [
|
|
{"index": 0, "status": "completed"},
|
|
{"index": 1, "status": "completed"},
|
|
]
|
|
persisted_template = next(obj for obj in session.added if isinstance(obj, TemplateV2))
|
|
assert persisted_template.layouts["layouts"][0]["id"] == "slide_1_4801"
|
|
assert persisted_template.layouts["layouts"][1]["id"] == "slide_2_4802"
|
|
|
|
|
|
def test_template_v2_request_ids_accept_non_uuid_strings():
|
|
layouts_request = CreateTemplateV2LayoutsRequest.model_validate(
|
|
{"id": "general-template", "indices": [0]}
|
|
)
|
|
blocks_request = GenerateTemplateV2BlocksRequest.model_validate(
|
|
{"template_id": "sales-template"}
|
|
)
|
|
|
|
assert layouts_request.template_id == "general-template"
|
|
assert blocks_request.template_id == "sales-template"
|
|
|
|
|
|
def test_init_template_v2_persists_assets_without_layouts(tmp_path, fake_async_session):
|
|
pptx_path = tmp_path / "quarterly-review.pptx"
|
|
pptx_path.write_bytes(b"pptx")
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.resolve_app_path_to_filesystem",
|
|
return_value=str(pptx_path),
|
|
), patch(
|
|
"api.v1.ppt.endpoints.templates.EXPORT_TASK_SERVICE.convert_pptx_to_json",
|
|
new=AsyncMock(return_value=PptxToJsonDocument(**RAW_LAYOUTS)),
|
|
) as convert_mock:
|
|
template_id = asyncio.run(
|
|
init_template_v2(
|
|
InitTemplateV2Request(
|
|
pptx_url="/app_data/uploads/quarterly-review.pptx",
|
|
slide_image_urls=["/app_data/images/slide-1.png"],
|
|
fonts={"Inter": "https://example.com/inter.css"},
|
|
name="Quarterly Review",
|
|
description="Board deck template",
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert isinstance(template_id, str)
|
|
convert_mock.assert_awaited_once_with(str(pptx_path))
|
|
template = fake_async_session.added[0]
|
|
assert template.name == "Quarterly Review"
|
|
assert template.description == "Board deck template"
|
|
assert template.raw_layouts == RAW_LAYOUTS
|
|
assert template.layouts is None
|
|
assert template.assets == {
|
|
"pptx_url": "/app_data/uploads/quarterly-review.pptx",
|
|
"fonts": {"Inter": "https://example.com/inter.css"},
|
|
"slide_image_urls": ["/app_data/images/slide-1.png"],
|
|
"images": ["/app_data/pptx-to-json/session/images/photo.png"],
|
|
"layout_indexes": [],
|
|
}
|
|
assert fake_async_session.commit_count == 1
|
|
|
|
|
|
def test_list_templates_v2_returns_paginated_summary():
|
|
response = asyncio.run(
|
|
list_templates_v2(page=1, page_size=20, sql_session=_ListSession())
|
|
)
|
|
|
|
assert response.total == 1
|
|
assert response.page == 1
|
|
assert response.page_size == 20
|
|
assert len(response.items) == 1
|
|
assert response.items[0].id == "00000000-0000-0000-0000-000000000001"
|
|
assert response.items[0].name == "Quarterly Review"
|
|
assert response.items[0].description == "Board deck template"
|
|
assert response.items[0].layout_count == 1
|
|
assert response.items[0].thumbnail == "/app_data/images/slide-1.png"
|
|
assert response.items[0].is_default is False
|
|
|
|
|
|
def test_create_template_v2_slide_layouts_returns_generated_layout(
|
|
fake_async_session,
|
|
):
|
|
template_id = str(uuid.uuid4())
|
|
template = TemplateV2(
|
|
name="Custom",
|
|
raw_layouts=RAW_LAYOUTS,
|
|
layouts=TEMPLATE_LAYOUTS,
|
|
assets={
|
|
"fonts": {"Inter": "https://example.com/inter.css"},
|
|
"slide_image_urls": ["/app_data/images/slide-1.png"],
|
|
},
|
|
)
|
|
fake_async_session._get_results[template_id] = template
|
|
request = CreateTemplateV2LayoutsRequest.model_validate(
|
|
{"id": str(template_id), "indices": [0]}
|
|
)
|
|
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.generate_slide_layout",
|
|
new=Mock(return_value=GENERATED_LAYOUTS.layouts[0]),
|
|
) as generate_mock, patch(
|
|
"api.v1.ppt.endpoints.templates.random.randint",
|
|
return_value=4801,
|
|
):
|
|
response = asyncio.run(
|
|
create_template_v2_slide_layouts(
|
|
request,
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
generate_mock.assert_called_once()
|
|
source_layout, slide_index, slide_image_url, fonts = generate_mock.call_args.args
|
|
assert source_layout.id == "slide_1"
|
|
assert slide_index == 0
|
|
assert slide_image_url == "/app_data/images/slide-1.png"
|
|
assert fonts == {"Inter": "https://example.com/inter.css"}
|
|
assert generate_mock.call_args.kwargs == {"max_tokens": 16000}
|
|
assert response.layouts[0].index == 0
|
|
response_layout = response.layouts[0].layout.model_dump(
|
|
mode="json", exclude_none=True
|
|
)
|
|
assert response_layout == {
|
|
**TEMPLATE_LAYOUTS["layouts"][0],
|
|
"id": "slide_1_4801",
|
|
}
|
|
|
|
|
|
def test_create_template_v2_slide_layouts_rejects_invalid_index(
|
|
fake_async_session,
|
|
):
|
|
template_id = str(uuid.uuid4())
|
|
fake_async_session._get_results[template_id] = TemplateV2(
|
|
name="Custom",
|
|
raw_layouts=RAW_LAYOUTS,
|
|
layouts=TEMPLATE_LAYOUTS,
|
|
assets={"slide_image_urls": ["/app_data/images/slide-1.png"]},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
create_template_v2_slide_layouts(
|
|
CreateTemplateV2LayoutsRequest(
|
|
template_id=template_id,
|
|
indices=[1],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert exc.value.detail == "Invalid slide index"
|
|
|
|
|
|
def test_create_template_v2_slide_layouts_requires_slide_image(
|
|
fake_async_session,
|
|
):
|
|
template_id = str(uuid.uuid4())
|
|
fake_async_session._get_results[template_id] = TemplateV2(
|
|
name="Custom",
|
|
raw_layouts=RAW_LAYOUTS,
|
|
layouts=TEMPLATE_LAYOUTS,
|
|
assets={"slide_image_urls": []},
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
create_template_v2_slide_layouts(
|
|
CreateTemplateV2LayoutsRequest(
|
|
template_id=template_id,
|
|
indices=[0],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert (
|
|
exc.value.detail == "Slide image URL is unavailable for requested slide index"
|
|
)
|
|
|
|
|
|
def test_create_template_v2_slide_layouts_preserves_image_url_indexes(
|
|
fake_async_session,
|
|
):
|
|
template_id = str(uuid.uuid4())
|
|
raw_layouts = {
|
|
"layouts": [
|
|
RAW_LAYOUTS["layouts"][0],
|
|
{
|
|
**RAW_LAYOUTS["layouts"][0],
|
|
"id": "slide_2",
|
|
"description": "Full slide layout converted from PPTX slide 2.",
|
|
},
|
|
]
|
|
}
|
|
fake_async_session._get_results[template_id] = TemplateV2(
|
|
name="Custom",
|
|
raw_layouts=raw_layouts,
|
|
layouts=TEMPLATE_LAYOUTS,
|
|
assets={"slide_image_urls": ["", "/app_data/images/slide-2.png"]},
|
|
)
|
|
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.generate_slide_layout",
|
|
new=Mock(return_value=GENERATED_LAYOUTS.layouts[0]),
|
|
) as generate_mock:
|
|
asyncio.run(
|
|
create_template_v2_slide_layouts(
|
|
CreateTemplateV2LayoutsRequest(
|
|
template_id=template_id,
|
|
indices=[1],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
source_layout, slide_index, slide_image_url, _fonts = generate_mock.call_args.args
|
|
assert source_layout.id == "slide_2"
|
|
assert slide_index == 1
|
|
assert slide_image_url == "/app_data/images/slide-2.png"
|
|
assert generate_mock.call_args.kwargs == {"max_tokens": 16000}
|
|
|
|
|
|
def test_create_template_v2_slide_layouts_returns_404_for_missing_template(
|
|
fake_async_session,
|
|
):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
create_template_v2_slide_layouts(
|
|
CreateTemplateV2LayoutsRequest(
|
|
template_id=str(uuid.uuid4()),
|
|
indices=[0],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 404
|
|
assert exc.value.detail == "Template not found"
|
|
|
|
|
|
def test_generate_template_v2_blocks_clusters_and_persists(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
template = TemplateV2(
|
|
name="Custom",
|
|
layouts=TEMPLATE_LAYOUTS,
|
|
raw_layouts=RAW_LAYOUTS,
|
|
)
|
|
fake_async_session._get_results[template_id] = template
|
|
|
|
with patch(
|
|
"api.v1.ppt.endpoints.templates.merge_similar_components",
|
|
new=Mock(return_value=MERGED_COMPONENTS),
|
|
) as merge_mock:
|
|
response = asyncio.run(
|
|
generate_template_v2_blocks(
|
|
GenerateTemplateV2BlocksRequest.model_validate(
|
|
{"id": str(template_id)}
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
merge_mock.assert_called_once()
|
|
layouts_arg = merge_mock.call_args.args[0]
|
|
assert layouts_arg.layouts[0].id == "slide_1"
|
|
assert response == template
|
|
assert template.merged_components == MERGED_COMPONENTS.model_dump(
|
|
mode="json", exclude_none=True
|
|
)
|
|
assert fake_async_session.commit_count == 1
|
|
|
|
|
|
def test_generate_template_v2_blocks_requires_layouts(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
fake_async_session._get_results[template_id] = TemplateV2(
|
|
name="Custom",
|
|
layouts=None,
|
|
raw_layouts=RAW_LAYOUTS,
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
generate_template_v2_blocks(
|
|
GenerateTemplateV2BlocksRequest(template_id=template_id),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert exc.value.detail == "Template layouts are unavailable"
|
|
assert fake_async_session.commit_count == 0
|
|
|
|
|
|
def test_patch_template_v2_slide_layout_updates_stored_layouts(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
template = TemplateV2(
|
|
name="Custom",
|
|
layouts=_two_template_layouts(),
|
|
raw_layouts=RAW_LAYOUTS,
|
|
)
|
|
fake_async_session._get_results[template_id] = template
|
|
patched_layout = {
|
|
**TEMPLATE_LAYOUTS["layouts"][0],
|
|
"id": "slide_2_updated",
|
|
"description": "Updated full slide layout converted from PPTX slide 2.",
|
|
}
|
|
|
|
response = asyncio.run(
|
|
patch_template_v2_slide_layout(
|
|
template_id,
|
|
PatchTemplateV2SlideLayoutRequest(
|
|
index=1,
|
|
layout=patched_layout,
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert response == template
|
|
assert template.layouts["layouts"][0]["id"] == "slide_1"
|
|
assert template.layouts["layouts"][1] == patched_layout
|
|
assert template.assets == {"layout_indexes": [0, 1]}
|
|
assert fake_async_session.commit_count == 1
|
|
|
|
|
|
def test_patch_template_v2_slide_layout_merges_out_of_order_init_saves(
|
|
fake_async_session,
|
|
):
|
|
template_id = str(uuid.uuid4())
|
|
raw_layouts = {
|
|
"layouts": [
|
|
RAW_LAYOUTS["layouts"][0],
|
|
{
|
|
**RAW_LAYOUTS["layouts"][0],
|
|
"id": "slide_2",
|
|
"description": "Full slide layout converted from PPTX slide 2.",
|
|
},
|
|
]
|
|
}
|
|
template = TemplateV2(
|
|
name="Custom",
|
|
raw_layouts=raw_layouts,
|
|
layouts=None,
|
|
assets={"layout_indexes": []},
|
|
)
|
|
fake_async_session._get_results[template_id] = template
|
|
second_layout = {
|
|
**TEMPLATE_LAYOUTS["layouts"][0],
|
|
"id": "slide_2",
|
|
"description": "Full slide layout converted from PPTX slide 2.",
|
|
}
|
|
|
|
asyncio.run(
|
|
patch_template_v2_slide_layout(
|
|
template_id,
|
|
PatchTemplateV2SlideLayoutRequest(
|
|
layouts=[{"index": 1, "layout": second_layout}],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
asyncio.run(
|
|
patch_template_v2_slide_layout(
|
|
template_id,
|
|
PatchTemplateV2SlideLayoutRequest(
|
|
layouts=[{"index": 0, "layout": TEMPLATE_LAYOUTS["layouts"][0]}],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert template.assets == {"layout_indexes": [0, 1]}
|
|
assert template.layouts["layouts"][0]["id"] == "slide_1"
|
|
assert template.layouts["layouts"][1]["id"] == "slide_2"
|
|
assert fake_async_session.commit_count == 2
|
|
|
|
|
|
def test_patch_template_v2_slide_layout_rejects_invalid_index(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
fake_async_session._get_results[template_id] = TemplateV2(
|
|
name="Custom",
|
|
layouts=TEMPLATE_LAYOUTS,
|
|
raw_layouts=RAW_LAYOUTS,
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
patch_template_v2_slide_layout(
|
|
template_id,
|
|
PatchTemplateV2SlideLayoutRequest(
|
|
index=1,
|
|
layout=TEMPLATE_LAYOUTS["layouts"][0],
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert exc.value.detail == "Invalid slide index"
|
|
assert fake_async_session.commit_count == 0
|
|
|
|
|
|
def test_patch_template_v2_slide_layout_rejects_duplicate_layout_ids(
|
|
fake_async_session,
|
|
):
|
|
template_id = str(uuid.uuid4())
|
|
fake_async_session._get_results[template_id] = TemplateV2(
|
|
name="Custom",
|
|
layouts=_two_template_layouts(),
|
|
raw_layouts=RAW_LAYOUTS,
|
|
)
|
|
duplicate_layout = {
|
|
**TEMPLATE_LAYOUTS["layouts"][0],
|
|
"id": "slide_1",
|
|
"description": "Duplicate ID full slide layout converted from PPTX slide 2.",
|
|
}
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
patch_template_v2_slide_layout(
|
|
template_id,
|
|
PatchTemplateV2SlideLayoutRequest(
|
|
index=1,
|
|
layout=duplicate_layout,
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert exc.value.detail == "Patched template layouts are invalid"
|
|
assert fake_async_session.commit_count == 0
|
|
|
|
|
|
def test_get_template_v2_returns_template(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
template = TemplateV2(name="Custom", layouts=RAW_LAYOUTS)
|
|
fake_async_session._get_results[template_id] = template
|
|
|
|
response = asyncio.run(
|
|
get_template_v2(template_id, sql_session=fake_async_session)
|
|
)
|
|
|
|
assert response == template
|
|
|
|
|
|
def test_update_template_v2_metadata_updates_name_and_description(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
template = TemplateV2(name="Custom", description=None, layouts=RAW_LAYOUTS)
|
|
fake_async_session._get_results[template_id] = template
|
|
|
|
response = asyncio.run(
|
|
update_template_v2_metadata(
|
|
template_id,
|
|
UpdateTemplateV2MetadataRequest(
|
|
name=" Sales Template ",
|
|
description=" Quarterly report deck ",
|
|
),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert response == template
|
|
assert template.name == "Sales Template"
|
|
assert template.description == "Quarterly report deck"
|
|
assert fake_async_session.added == [template]
|
|
assert fake_async_session.commit_count == 1
|
|
|
|
|
|
def test_update_template_v2_metadata_rejects_blank_name(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
template = TemplateV2(name="Custom", layouts=RAW_LAYOUTS)
|
|
fake_async_session._get_results[template_id] = template
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(
|
|
update_template_v2_metadata(
|
|
template_id,
|
|
UpdateTemplateV2MetadataRequest(name=" "),
|
|
sql_session=fake_async_session,
|
|
)
|
|
)
|
|
|
|
assert exc.value.status_code == 400
|
|
assert exc.value.detail == "Template name is required"
|
|
assert template.name == "Custom"
|
|
assert fake_async_session.commit_count == 0
|
|
|
|
|
|
def test_delete_template_v2_deletes_template(fake_async_session):
|
|
template_id = str(uuid.uuid4())
|
|
template = TemplateV2(name="Custom", layouts=RAW_LAYOUTS)
|
|
fake_async_session._get_results[template_id] = template
|
|
|
|
response = asyncio.run(
|
|
delete_template_v2(template_id, sql_session=fake_async_session)
|
|
)
|
|
|
|
assert response.status_code == 204
|
|
assert fake_async_session.deleted == [template]
|
|
assert fake_async_session.commit_count == 1
|
|
|
|
|
|
def test_get_template_v2_returns_404_for_missing_template(fake_async_session):
|
|
with pytest.raises(HTTPException) as exc:
|
|
asyncio.run(get_template_v2("missing-template", sql_session=fake_async_session))
|
|
|
|
assert exc.value.status_code == 404
|
|
assert exc.value.detail == "Template not found"
|