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"