diff --git a/tests/conftest.py b/tests/conftest.py index be4bb7a..24db71b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -23,10 +23,9 @@ SEED_IDS = { "meeting": "a0000000-0000-0000-0000-000000000007", "decision": "a0000000-0000-0000-0000-000000000008", "appointment": "a0000000-0000-0000-0000-000000000009", - "weblink_folder": "a0000000-0000-0000-0000-00000000000a", + "link_folder": "a0000000-0000-0000-0000-00000000000a", "list": "a0000000-0000-0000-0000-00000000000b", "link": "a0000000-0000-0000-0000-00000000000c", - "weblink": "a0000000-0000-0000-0000-00000000000d", "capture": "a0000000-0000-0000-0000-00000000000e", "focus": "a0000000-0000-0000-0000-00000000000f", "process": "a0000000-0000-0000-0000-000000000010", @@ -34,6 +33,7 @@ SEED_IDS = { "process_run": "a0000000-0000-0000-0000-000000000012", "time_budget": "a0000000-0000-0000-0000-000000000013", "file": "a0000000-0000-0000-0000-000000000014", + "focus_standalone": "a0000000-0000-0000-0000-000000000015", } @@ -94,140 +94,179 @@ def all_seeds(sync_conn): cur.execute(""" INSERT INTO domains (id, name, color, description, sort_order, is_deleted, created_at, updated_at) VALUES (%s, 'Test Domain', '#FF5733', 'Auto test domain', 0, false, now(), now()) - ON CONFLICT (id) DO NOTHING + ON CONFLICT (id) DO UPDATE SET name='Test Domain', color='#FF5733', description='Auto test domain', + sort_order=0, is_deleted=false, deleted_at=NULL, updated_at=now() """, (d["domain"],)) # Area cur.execute(""" INSERT INTO areas (id, name, domain_id, description, status, sort_order, is_deleted, created_at, updated_at) VALUES (%s, 'Test Area', %s, 'Auto test area', 'active', 0, false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["area"], d["domain"])) + ON CONFLICT (id) DO UPDATE SET name='Test Area', domain_id=%s, description='Auto test area', + status='active', sort_order=0, is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["area"], d["domain"], d["domain"])) # Project cur.execute(""" INSERT INTO projects (id, name, domain_id, area_id, description, status, priority, sort_order, is_deleted, created_at, updated_at) VALUES (%s, 'Test Project', %s, %s, 'Auto test project', 'active', 2, 0, false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["project"], d["domain"], d["area"])) + ON CONFLICT (id) DO UPDATE SET name='Test Project', domain_id=%s, area_id=%s, + description='Auto test project', status='active', priority=2, sort_order=0, + is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["project"], d["domain"], d["area"], d["domain"], d["area"])) # Task (status='open' matches DB default, not 'todo') cur.execute(""" INSERT INTO tasks (id, title, domain_id, project_id, description, priority, status, sort_order, is_deleted, created_at, updated_at) VALUES (%s, 'Test Task', %s, %s, 'Auto test task', 2, 'open', 0, false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["task"], d["domain"], d["project"])) + ON CONFLICT (id) DO UPDATE SET title='Test Task', domain_id=%s, project_id=%s, + description='Auto test task', priority=2, status='open', sort_order=0, + is_deleted=false, deleted_at=NULL, completed_at=NULL, updated_at=now() + """, (d["task"], d["domain"], d["project"], d["domain"], d["project"])) # Contact cur.execute(""" INSERT INTO contacts (id, first_name, last_name, company, email, is_deleted, created_at, updated_at) VALUES (%s, 'Test', 'Contact', 'TestCorp', 'test@example.com', false, now(), now()) - ON CONFLICT (id) DO NOTHING + ON CONFLICT (id) DO UPDATE SET first_name='Test', last_name='Contact', company='TestCorp', + email='test@example.com', is_deleted=false, deleted_at=NULL, updated_at=now() """, (d["contact"],)) # Note cur.execute(""" INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) VALUES (%s, 'Test Note', %s, 'Test body content', 'markdown', false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["note"], d["domain"])) + ON CONFLICT (id) DO UPDATE SET title='Test Note', domain_id=%s, body='Test body content', + content_format='markdown', is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["note"], d["domain"], d["domain"])) # Meeting cur.execute(""" INSERT INTO meetings (id, title, meeting_date, status, is_deleted, created_at, updated_at) VALUES (%s, 'Test Meeting', '2025-06-15', 'scheduled', false, now(), now()) - ON CONFLICT (id) DO NOTHING + ON CONFLICT (id) DO UPDATE SET title='Test Meeting', meeting_date='2025-06-15', + status='scheduled', is_deleted=false, deleted_at=NULL, updated_at=now() """, (d["meeting"],)) # Decision cur.execute(""" INSERT INTO decisions (id, title, status, impact, is_deleted, created_at, updated_at) VALUES (%s, 'Test Decision', 'decided', 'high', false, now(), now()) - ON CONFLICT (id) DO NOTHING + ON CONFLICT (id) DO UPDATE SET title='Test Decision', status='decided', impact='high', + is_deleted=false, deleted_at=NULL, updated_at=now() """, (d["decision"],)) # Appointment cur.execute(""" INSERT INTO appointments (id, title, start_at, end_at, all_day, is_deleted, created_at, updated_at) VALUES (%s, 'Test Appointment', '2025-06-15 10:00:00', '2025-06-15 11:00:00', false, false, now(), now()) - ON CONFLICT (id) DO NOTHING + ON CONFLICT (id) DO UPDATE SET title='Test Appointment', start_at='2025-06-15 10:00:00', + end_at='2025-06-15 11:00:00', all_day=false, is_deleted=false, deleted_at=NULL, updated_at=now() """, (d["appointment"],)) - # Weblink folder + # Link folder cur.execute(""" - INSERT INTO weblink_folders (id, name, is_deleted, created_at, updated_at) + INSERT INTO link_folders (id, name, is_deleted, created_at, updated_at) VALUES (%s, 'Test Folder', false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["weblink_folder"],)) + ON CONFLICT (id) DO UPDATE SET name='Test Folder', is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["link_folder"],)) # List cur.execute(""" INSERT INTO lists (id, name, domain_id, project_id, list_type, is_deleted, created_at, updated_at) VALUES (%s, 'Test List', %s, %s, 'checklist', false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["list"], d["domain"], d["project"])) + ON CONFLICT (id) DO UPDATE SET name='Test List', domain_id=%s, project_id=%s, + list_type='checklist', is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["list"], d["domain"], d["project"], d["domain"], d["project"])) # Link cur.execute(""" INSERT INTO links (id, label, url, domain_id, is_deleted, created_at, updated_at) VALUES (%s, 'Test Link', 'https://example.com', %s, false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["link"], d["domain"])) + ON CONFLICT (id) DO UPDATE SET label='Test Link', url='https://example.com', domain_id=%s, + is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["link"], d["domain"], d["domain"])) - # Weblink + # Link folder junction cur.execute(""" - INSERT INTO weblinks (id, label, url, is_deleted, created_at, updated_at) - VALUES (%s, 'Test Weblink', 'https://example.com/wl', false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["weblink"],)) - - # Link weblink to folder via junction table - cur.execute(""" - INSERT INTO folder_weblinks (folder_id, weblink_id) + INSERT INTO folder_links (folder_id, link_id) VALUES (%s, %s) ON CONFLICT DO NOTHING - """, (d["weblink_folder"], d["weblink"])) + """, (d["link_folder"], d["link"])) # Capture cur.execute(""" INSERT INTO capture (id, raw_text, processed, is_deleted, created_at, updated_at) VALUES (%s, 'Test capture item', false, false, now(), now()) - ON CONFLICT (id) DO NOTHING + ON CONFLICT (id) DO UPDATE SET raw_text='Test capture item', processed=false, + is_deleted=false, deleted_at=NULL, updated_at=now() """, (d["capture"],)) - # Daily focus + # Daily focus (task-linked) cur.execute(""" INSERT INTO daily_focus (id, task_id, focus_date, completed, created_at, updated_at) VALUES (%s, %s, CURRENT_DATE, false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["focus"], d["task"])) + ON CONFLICT (id) DO UPDATE SET task_id=%s, focus_date=CURRENT_DATE, completed=false, + is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["focus"], d["task"], d["task"])) + + # Daily focus (standalone text item) + cur.execute(""" + INSERT INTO daily_focus (id, focus_date, completed, title, domain_id, project_id, created_at, updated_at) + VALUES (%s, CURRENT_DATE, false, 'Test Standalone Focus', %s, %s, now(), now()) + ON CONFLICT (id) DO UPDATE SET focus_date=CURRENT_DATE, completed=false, + title='Test Standalone Focus', domain_id=%s, project_id=%s, + is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["focus_standalone"], d["domain"], d["project"], d["domain"], d["project"])) + + # Junction table seeds for contact tabs + cur.execute(""" + INSERT INTO contact_tasks (contact_id, task_id, role) + VALUES (%s, %s, 'assignee') ON CONFLICT DO NOTHING + """, (d["contact"], d["task"])) + cur.execute(""" + INSERT INTO contact_projects (contact_id, project_id, role) + VALUES (%s, %s, 'stakeholder') ON CONFLICT DO NOTHING + """, (d["contact"], d["project"])) + cur.execute(""" + INSERT INTO contact_meetings (contact_id, meeting_id, role) + VALUES (%s, %s, 'attendee') ON CONFLICT DO NOTHING + """, (d["contact"], d["meeting"])) + cur.execute(""" + INSERT INTO contact_lists (contact_id, list_id, role) + VALUES (%s, %s, 'contributor') ON CONFLICT DO NOTHING + """, (d["contact"], d["list"])) # Process cur.execute(""" INSERT INTO processes (id, name, process_type, status, category, is_deleted, created_at, updated_at) VALUES (%s, 'Test Process', 'checklist', 'active', 'Testing', false, now(), now()) - ON CONFLICT (id) DO NOTHING + ON CONFLICT (id) DO UPDATE SET name='Test Process', process_type='checklist', status='active', + category='Testing', is_deleted=false, deleted_at=NULL, updated_at=now() """, (d["process"],)) # Process step cur.execute(""" INSERT INTO process_steps (id, process_id, title, instructions, sort_order, is_deleted, created_at, updated_at) VALUES (%s, %s, 'Test Step', 'Do the thing', 0, false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["process_step"], d["process"])) + ON CONFLICT (id) DO UPDATE SET process_id=%s, title='Test Step', instructions='Do the thing', + sort_order=0, is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["process_step"], d["process"], d["process"])) # Process run cur.execute(""" INSERT INTO process_runs (id, process_id, title, status, process_type, task_generation, is_deleted, created_at, updated_at) VALUES (%s, %s, 'Test Run', 'not_started', 'checklist', 'all_at_once', false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["process_run"], d["process"])) + ON CONFLICT (id) DO UPDATE SET process_id=%s, title='Test Run', status='not_started', + process_type='checklist', task_generation='all_at_once', is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["process_run"], d["process"], d["process"])) # Time budget cur.execute(""" INSERT INTO time_budgets (id, domain_id, weekly_hours, effective_from, is_deleted, created_at, updated_at) VALUES (%s, %s, 10, CURRENT_DATE, false, now(), now()) - ON CONFLICT (id) DO NOTHING - """, (d["time_budget"], d["domain"])) + ON CONFLICT (id) DO UPDATE SET domain_id=%s, weekly_hours=10, effective_from=CURRENT_DATE, + is_deleted=false, deleted_at=NULL, updated_at=now() + """, (d["time_budget"], d["domain"], d["domain"])) # File (create a dummy file on disk for download/serve tests) import os @@ -253,6 +292,11 @@ def all_seeds(sync_conn): # Cleanup: delete all seed data (reverse dependency order) try: + # Junction tables first + cur.execute("DELETE FROM contact_tasks WHERE task_id = %s", (d["task"],)) + cur.execute("DELETE FROM contact_projects WHERE project_id = %s", (d["project"],)) + cur.execute("DELETE FROM contact_meetings WHERE meeting_id = %s", (d["meeting"],)) + cur.execute("DELETE FROM contact_lists WHERE list_id = %s", (d["list"],)) cur.execute("DELETE FROM files WHERE id = %s", (d["file"],)) if os.path.exists(dummy_file_path): os.remove(dummy_file_path) @@ -260,13 +304,13 @@ def all_seeds(sync_conn): cur.execute("DELETE FROM process_runs WHERE id = %s", (d["process_run"],)) cur.execute("DELETE FROM process_steps WHERE id = %s", (d["process_step"],)) cur.execute("DELETE FROM processes WHERE id = %s", (d["process"],)) + cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus_standalone"],)) cur.execute("DELETE FROM daily_focus WHERE id = %s", (d["focus"],)) cur.execute("DELETE FROM capture WHERE id = %s", (d["capture"],)) - cur.execute("DELETE FROM folder_weblinks WHERE weblink_id = %s", (d["weblink"],)) - cur.execute("DELETE FROM weblinks WHERE id = %s", (d["weblink"],)) + cur.execute("DELETE FROM folder_links WHERE link_id = %s", (d["link"],)) cur.execute("DELETE FROM links WHERE id = %s", (d["link"],)) cur.execute("DELETE FROM lists WHERE id = %s", (d["list"],)) - cur.execute("DELETE FROM weblink_folders WHERE id = %s", (d["weblink_folder"],)) + cur.execute("DELETE FROM link_folders WHERE id = %s", (d["link_folder"],)) cur.execute("DELETE FROM appointments WHERE id = %s", (d["appointment"],)) cur.execute("DELETE FROM decisions WHERE id = %s", (d["decision"],)) cur.execute("DELETE FROM meetings WHERE id = %s", (d["meeting"],)) @@ -336,3 +380,7 @@ def seed_list(all_seeds): @pytest.fixture(scope="session") def seed_appointment(all_seeds): return {"id": all_seeds["appointment"], "title": "Test Appointment"} + +@pytest.fixture(scope="session") +def seed_focus_standalone(all_seeds): + return {"id": all_seeds["focus_standalone"], "title": "Test Standalone Focus"} diff --git a/tests/form_factory.py b/tests/form_factory.py index 407ace5..031bcfc 100644 --- a/tests/form_factory.py +++ b/tests/form_factory.py @@ -24,15 +24,18 @@ from tests.introspect import FormField # FK fields map to seed fixture keys FK_FIELD_MAP = { "domain_id": "domain", + "quick_domain_id": "domain", "area_id": "area", "project_id": "project", "task_id": "task", "folder_id": "weblink_folder", "parent_id": None, # Usually optional, skip - "meeting_id": None, + "meeting_id": "meeting", "contact_id": "contact", "release_id": None, "note_id": "note", + "link_id": "link", + "decision_id": "decision", "list_id": "list", "process_id": "process", "run_id": "process_run", @@ -80,6 +83,11 @@ NAME_PATTERNS: list[tuple[str, Any]] = [ ("notes", "Test notes field"), ("weekly_hours", "10"), ("effective_from", None), # Resolved dynamically as date + ("item_ids", ""), # Comma-separated UUIDs for reorder + ("item_id", ""), # Single item ID for reorder + ("direction", "up"), # Reorder direction + ("label", "Test Label Auto"), # Link label + ("content", "Test content"), # List item content ] diff --git a/tests/test_business_logic.py b/tests/test_business_logic.py index 851a9ce..0a9178c 100644 --- a/tests/test_business_logic.py +++ b/tests/test_business_logic.py @@ -520,11 +520,11 @@ class TestCaptureConversions: assert dec.impact == "medium" @pytest.mark.asyncio - async def test_convert_to_weblink( + async def test_convert_to_link( self, client: AsyncClient, db_session: AsyncSession, ): cap_id = await _create_capture(db_session, "Check https://example.com/test for details") - r = await client.post(f"/capture/{cap_id}/to-weblink", data={}, follow_redirects=False) + r = await client.post(f"/capture/{cap_id}/to-link", data={}, follow_redirects=False) assert r.status_code == 303 result = await db_session.execute( @@ -532,10 +532,10 @@ class TestCaptureConversions: {"id": cap_id}, ) row = result.first() - assert row.converted_to_type == "weblink" + assert row.converted_to_type == "link" # URL should be extracted result = await db_session.execute( - text("SELECT url FROM weblinks WHERE id = :id"), {"id": row.converted_to_id}, + text("SELECT url FROM links WHERE id = :id"), {"id": row.converted_to_id}, ) assert "https://example.com/test" in result.first().url @@ -1165,9 +1165,9 @@ class TestFocusWorkflow: await _delete_tasks(db_session, [tid]) @pytest.mark.asyncio - async def test_focus_page_loads_with_date(self, client: AsyncClient): - """Focus page loads for specific date.""" - r = await client.get(f"/focus/?focus_date={date.today()}") + async def test_focus_page_loads(self, client: AsyncClient): + """Focus page loads without date filter (permanent items).""" + r = await client.get("/focus/", follow_redirects=True) assert r.status_code == 200 @@ -1893,3 +1893,1054 @@ async def _create_list_item(db: AsyncSession, list_id: str, content: str) -> str ) await db.commit() return _id + + +# =========================================================================== +# Task Detail Tabs +# =========================================================================== +class TestTaskDetailTabs: + """Test tabbed task detail page - all tabs load, correct content per tab.""" + + @pytest.mark.asyncio + async def test_overview_tab_default(self, client: AsyncClient, seed_task: dict): + """Default tab is overview.""" + r = await client.get(f"/tasks/{seed_task['id']}") + assert r.status_code == 200 + # The active tab has class "tab-item active" + assert 'tab-item active' in r.text + + @pytest.mark.asyncio + async def test_all_tabs_return_200(self, client: AsyncClient, seed_task: dict): + """Every tab on task detail returns 200.""" + for tab in ("overview", "notes", "links", "files", "lists", "decisions", "processes", "contacts"): + r = await client.get(f"/tasks/{seed_task['id']}?tab={tab}") + assert r.status_code == 200, f"Tab '{tab}' returned {r.status_code}" + + @pytest.mark.asyncio + async def test_contacts_tab_shows_seed_contact( + self, client: AsyncClient, seed_task: dict, seed_contact: dict, + ): + """Contacts tab shows the linked contact from seed data.""" + r = await client.get(f"/tasks/{seed_task['id']}?tab=contacts") + assert r.status_code == 200 + assert seed_contact["first_name"] in r.text + + @pytest.mark.asyncio + async def test_notes_tab_shows_linked_note( + self, client: AsyncClient, db_session: AsyncSession, + seed_task: dict, seed_domain: dict, + ): + """A note linked to a task appears on the notes tab.""" + tag = _uid() + note_id = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO notes (id, title, domain_id, task_id, body, content_format, is_deleted, created_at, updated_at) " + "VALUES (:id, :title, :did, :tid, 'body', 'markdown', false, now(), now())"), + {"id": note_id, "title": f"TaskNote-{tag}", "did": seed_domain["id"], "tid": seed_task["id"]}, + ) + await db_session.commit() + + r = await client.get(f"/tasks/{seed_task['id']}?tab=notes") + assert f"TaskNote-{tag}" in r.text + + await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id}) + await db_session.commit() + + @pytest.mark.asyncio + async def test_invalid_tab_defaults_to_overview(self, client: AsyncClient, seed_task: dict): + """Invalid tab param still loads the page (defaults to overview).""" + r = await client.get(f"/tasks/{seed_task['id']}?tab=nonexistent") + assert r.status_code == 200 + + +# =========================================================================== +# Task Contact Management +# =========================================================================== +class TestTaskContactManagement: + """Test adding and removing contacts on task detail.""" + + @pytest.mark.asyncio + async def test_add_contact_to_task( + self, client: AsyncClient, db_session: AsyncSession, + seed_domain: dict, seed_project: dict, seed_contact: dict, + ): + """Adding a contact to a task creates a junction entry.""" + tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"ContactTask-{_uid()}") + + r = await client.post(f"/tasks/{tid}/contacts/add", data={ + "contact_id": seed_contact["id"], + "role": "reviewer", + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT role FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"), + {"tid": tid, "cid": seed_contact["id"]}, + ) + row = result.first() + assert row is not None, "Contact-task junction not created" + assert row.role == "reviewer" + + # Cleanup + await db_session.execute(text("DELETE FROM contact_tasks WHERE task_id = :tid"), {"tid": tid}) + await db_session.commit() + await _delete_tasks(db_session, [tid]) + + @pytest.mark.asyncio + async def test_remove_contact_from_task( + self, client: AsyncClient, db_session: AsyncSession, + seed_domain: dict, seed_project: dict, seed_contact: dict, + ): + """Removing a contact from a task deletes the junction entry.""" + tid = await _create_task(db_session, seed_domain["id"], seed_project["id"], f"RmContact-{_uid()}") + await db_session.execute( + text("INSERT INTO contact_tasks (contact_id, task_id, role) VALUES (:cid, :tid, 'test') ON CONFLICT DO NOTHING"), + {"cid": seed_contact["id"], "tid": tid}, + ) + await db_session.commit() + + r = await client.post(f"/tasks/{tid}/contacts/{seed_contact['id']}/remove", follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT count(*) FROM contact_tasks WHERE task_id = :tid AND contact_id = :cid"), + {"tid": tid, "cid": seed_contact["id"]}, + ) + assert result.scalar() == 0 + + await _delete_tasks(db_session, [tid]) + + @pytest.mark.asyncio + async def test_add_duplicate_contact_no_error( + self, client: AsyncClient, db_session: AsyncSession, + seed_task: dict, seed_contact: dict, + ): + """Adding same contact twice doesn't crash (ON CONFLICT DO NOTHING).""" + r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={ + "contact_id": seed_contact["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + r = await client.post(f"/tasks/{seed_task['id']}/contacts/add", data={ + "contact_id": seed_contact["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + +# =========================================================================== +# Project Detail Tabs +# =========================================================================== +class TestProjectDetailTabs: + """Test tabbed project detail page.""" + + @pytest.mark.asyncio + async def test_all_project_tabs_return_200(self, client: AsyncClient, seed_project: dict): + """Every tab on project detail returns 200.""" + for tab in ("tasks", "notes", "links", "files", "lists", "decisions", "processes", "contacts"): + r = await client.get(f"/projects/{seed_project['id']}?tab={tab}") + assert r.status_code == 200, f"Project tab '{tab}' returned {r.status_code}" + + @pytest.mark.asyncio + async def test_project_contacts_tab_shows_seed_contact( + self, client: AsyncClient, seed_project: dict, seed_contact: dict, + ): + """Contacts tab shows the linked contact.""" + r = await client.get(f"/projects/{seed_project['id']}?tab=contacts") + assert seed_contact["first_name"] in r.text + + @pytest.mark.asyncio + async def test_project_tasks_tab_shows_seed_task( + self, client: AsyncClient, db_session: AsyncSession, + seed_project: dict, seed_task: dict, + ): + """Tasks tab shows the seed task linked to this project.""" + # Ensure seed project and task are not soft-deleted (earlier tests may delete them) + await db_session.execute( + text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id"), + {"id": seed_project["id"]}, + ) + await db_session.execute( + text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"), + {"id": seed_task["id"]}, + ) + await db_session.commit() + + r = await client.get(f"/projects/{seed_project['id']}?tab=tasks") + assert seed_task["title"] in r.text + + +# =========================================================================== +# Project Contact Management +# =========================================================================== +class TestProjectContactManagement: + """Test adding and removing contacts on project detail.""" + + @pytest.mark.asyncio + async def test_add_contact_to_project( + self, client: AsyncClient, db_session: AsyncSession, + seed_project: dict, seed_contact: dict, + ): + """Adding a contact to a project creates a junction entry.""" + # First remove existing seed junction to test fresh add + await db_session.execute( + text("DELETE FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"), + {"pid": seed_project["id"], "cid": seed_contact["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/projects/{seed_project['id']}/contacts/add", data={ + "contact_id": seed_contact["id"], + "role": "lead", + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT role FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"), + {"pid": seed_project["id"], "cid": seed_contact["id"]}, + ) + row = result.first() + assert row is not None + assert row.role == "lead" + + @pytest.mark.asyncio + async def test_remove_contact_from_project( + self, client: AsyncClient, db_session: AsyncSession, + seed_project: dict, seed_contact: dict, + ): + """Removing a contact from a project deletes the junction entry.""" + # Ensure junction exists + await db_session.execute( + text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'test') ON CONFLICT DO NOTHING"), + {"cid": seed_contact["id"], "pid": seed_project["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/projects/{seed_project['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT count(*) FROM contact_projects WHERE project_id = :pid AND contact_id = :cid"), + {"pid": seed_project["id"], "cid": seed_contact["id"]}, + ) + assert result.scalar() == 0 + + # Re-insert for other tests + await db_session.execute( + text("INSERT INTO contact_projects (contact_id, project_id, role) VALUES (:cid, :pid, 'stakeholder') ON CONFLICT DO NOTHING"), + {"cid": seed_contact["id"], "pid": seed_project["id"]}, + ) + await db_session.commit() + + +# =========================================================================== +# Meeting Detail Tabs +# =========================================================================== +class TestMeetingDetailTabs: + """Test tabbed meeting detail page.""" + + @pytest.mark.asyncio + async def test_all_meeting_tabs_return_200(self, client: AsyncClient, seed_meeting: dict): + """Every tab on meeting detail returns 200.""" + for tab in ("overview", "notes", "links", "files", "lists", "processes", "contacts"): + r = await client.get(f"/meetings/{seed_meeting['id']}?tab={tab}") + assert r.status_code == 200, f"Meeting tab '{tab}' returned {r.status_code}" + + @pytest.mark.asyncio + async def test_meeting_contacts_tab_shows_seed_contact( + self, client: AsyncClient, seed_meeting: dict, seed_contact: dict, + ): + """Contacts tab shows the linked contact.""" + r = await client.get(f"/meetings/{seed_meeting['id']}?tab=contacts") + assert seed_contact["first_name"] in r.text + + +# =========================================================================== +# Meeting Contact Management +# =========================================================================== +class TestMeetingContactManagement: + """Test adding and removing contacts on meeting detail.""" + + @pytest.mark.asyncio + async def test_add_contact_to_meeting( + self, client: AsyncClient, db_session: AsyncSession, + seed_meeting: dict, seed_contact: dict, + ): + """Adding a contact to a meeting creates a junction entry.""" + await db_session.execute( + text("DELETE FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"), + {"mid": seed_meeting["id"], "cid": seed_contact["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/add", data={ + "contact_id": seed_contact["id"], + "role": "organizer", + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT role FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"), + {"mid": seed_meeting["id"], "cid": seed_contact["id"]}, + ) + row = result.first() + assert row is not None + assert row.role == "organizer" + + @pytest.mark.asyncio + async def test_remove_contact_from_meeting( + self, client: AsyncClient, db_session: AsyncSession, + seed_meeting: dict, seed_contact: dict, + ): + """Removing a contact from a meeting deletes the junction entry.""" + await db_session.execute( + text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"), + {"cid": seed_contact["id"], "mid": seed_meeting["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/meetings/{seed_meeting['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT count(*) FROM contact_meetings WHERE meeting_id = :mid AND contact_id = :cid"), + {"mid": seed_meeting["id"], "cid": seed_contact["id"]}, + ) + assert result.scalar() == 0 + + # Re-insert for other tests + await db_session.execute( + text("INSERT INTO contact_meetings (contact_id, meeting_id, role) VALUES (:cid, :mid, 'attendee') ON CONFLICT DO NOTHING"), + {"cid": seed_contact["id"], "mid": seed_meeting["id"]}, + ) + await db_session.commit() + + +# =========================================================================== +# List Contact Management +# =========================================================================== +class TestListContactManagement: + """Test adding and removing contacts on list detail.""" + + @pytest.mark.asyncio + async def test_add_contact_to_list( + self, client: AsyncClient, db_session: AsyncSession, + seed_list: dict, seed_contact: dict, + ): + """Adding a contact to a list creates a junction entry.""" + await db_session.execute( + text("DELETE FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"), + {"lid": seed_list["id"], "cid": seed_contact["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/lists/{seed_list['id']}/contacts/add", data={ + "contact_id": seed_contact["id"], + "role": "owner", + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT role FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"), + {"lid": seed_list["id"], "cid": seed_contact["id"]}, + ) + row = result.first() + assert row is not None + assert row.role == "owner" + + @pytest.mark.asyncio + async def test_remove_contact_from_list( + self, client: AsyncClient, db_session: AsyncSession, + seed_list: dict, seed_contact: dict, + ): + """Removing a contact from a list deletes the junction entry.""" + await db_session.execute( + text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'test') ON CONFLICT DO NOTHING"), + {"cid": seed_contact["id"], "lid": seed_list["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/lists/{seed_list['id']}/contacts/{seed_contact['id']}/remove", follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT count(*) FROM contact_lists WHERE list_id = :lid AND contact_id = :cid"), + {"lid": seed_list["id"], "cid": seed_contact["id"]}, + ) + assert result.scalar() == 0 + + # Re-insert for other tests + await db_session.execute( + text("INSERT INTO contact_lists (contact_id, list_id, role) VALUES (:cid, :lid, 'contributor') ON CONFLICT DO NOTHING"), + {"cid": seed_contact["id"], "lid": seed_list["id"]}, + ) + await db_session.commit() + + +# =========================================================================== +# History Page +# =========================================================================== +class TestHistoryPage: + """Test the change history page.""" + + @pytest.mark.asyncio + async def test_history_page_loads(self, client: AsyncClient): + """History page returns 200.""" + r = await client.get("/history/") + assert r.status_code == 200 + assert "Change History" in r.text + + @pytest.mark.asyncio + async def test_history_shows_seed_entities( + self, client: AsyncClient, db_session: AsyncSession, seed_task: dict, + ): + """History page shows recently modified seed entities.""" + # Ensure seed task is not soft-deleted (earlier tests may delete it) + await db_session.execute( + text("UPDATE tasks SET is_deleted = false, deleted_at = NULL WHERE id = :id"), + {"id": seed_task["id"]}, + ) + await db_session.commit() + + r = await client.get("/history/") + assert r.status_code == 200 + assert seed_task["title"] in r.text + + @pytest.mark.asyncio + async def test_history_entity_type_filter(self, client: AsyncClient): + """History page can filter by entity type.""" + for entity_type in ("tasks", "notes", "projects", "contacts"): + r = await client.get(f"/history/?entity_type={entity_type}") + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_history_shows_modified_vs_created( + self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, + ): + """Items with updated_at different from created_at show as 'modified'.""" + tag = _uid() + note_id = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) " + "VALUES (:id, :title, :did, 'body', 'markdown', false, now() - interval '1 hour', now())"), + {"id": note_id, "title": f"HistMod-{tag}", "did": seed_domain["id"]}, + ) + await db_session.commit() + + r = await client.get("/history/?entity_type=notes") + assert f"HistMod-{tag}" in r.text + assert "modified" in r.text + + await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id}) + await db_session.commit() + + +# =========================================================================== +# Search Prefix Matching +# =========================================================================== +class TestSearchPrefixMatching: + """Test the improved search with prefix tsquery and ILIKE fallback.""" + + @pytest.mark.asyncio + async def test_search_api_short_query_rejected(self, client: AsyncClient): + """Query shorter than 2 chars returns empty results.""" + r = await client.get("/search/api?q=a") + assert r.status_code == 200 + data = r.json() + assert len(data["results"]) == 0 + + @pytest.mark.asyncio + async def test_search_api_result_structure(self, client: AsyncClient): + """Search results have expected fields.""" + r = await client.get("/search/api?q=Test") + assert r.status_code == 200 + data = r.json() + if data["results"]: + result = data["results"][0] + assert "type" in result + assert "id" in result + assert "name" in result + assert "url" in result + assert "icon" in result + assert "rank" in result + + @pytest.mark.asyncio + async def test_search_page_returns_200(self, client: AsyncClient): + """Full search page works.""" + r = await client.get("/search/?q=Test") + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_search_ilike_fallback( + self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, + ): + """ILIKE fallback finds items that tsvector might miss.""" + tag = _uid() + # Create a note with a very specific title + note_id = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO notes (id, title, domain_id, body, content_format, is_deleted, created_at, updated_at) " + "VALUES (:id, :title, :did, 'body', 'markdown', false, now(), now())"), + {"id": note_id, "title": f"XqzNote{tag}", "did": seed_domain["id"]}, + ) + await db_session.commit() + + r = await client.get(f"/search/api?q=XqzNote{tag}&entity_type=notes") + data = r.json() + ids = [res["id"] for res in data["results"]] + assert note_id in ids, "ILIKE fallback should find exact substring match" + + await db_session.execute(text("DELETE FROM notes WHERE id = :id"), {"id": note_id}) + await db_session.commit() + + +# =========================================================================== +# Projects API (Dynamic Filtering) +# =========================================================================== +class TestProjectsAPI: + """Test the /projects/api/by-domain JSON endpoint.""" + + @pytest.mark.asyncio + async def test_projects_by_domain_returns_json( + self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, + ): + """API returns JSON list of projects for a domain.""" + # Ensure seed project is not soft-deleted (earlier tests may delete it) + await db_session.execute( + text("UPDATE projects SET is_deleted = false, deleted_at = NULL WHERE id = :id AND is_deleted = true"), + {"id": "a0000000-0000-0000-0000-000000000003"}, + ) + await db_session.commit() + + r = await client.get(f"/projects/api/by-domain?domain_id={seed_domain['id']}") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + assert any(p["name"] == "Test Project" for p in data) + + @pytest.mark.asyncio + async def test_projects_by_domain_empty(self, client: AsyncClient): + """API returns empty list for nonexistent domain.""" + fake_id = str(uuid.uuid4()) + r = await client.get(f"/projects/api/by-domain?domain_id={fake_id}") + assert r.status_code == 200 + data = r.json() + assert data == [] + + @pytest.mark.asyncio + async def test_projects_by_domain_no_param(self, client: AsyncClient): + """API without domain_id returns all active projects.""" + r = await client.get("/projects/api/by-domain") + assert r.status_code == 200 + data = r.json() + assert isinstance(data, list) + + +# =========================================================================== +# Eisenhower Matrix Filters +# =========================================================================== +class TestEisenhowerFilters: + """Test Eisenhower matrix page with filter parameters.""" + + @pytest.mark.asyncio + async def test_eisenhower_page_loads(self, client: AsyncClient): + """Eisenhower page returns 200.""" + r = await client.get("/eisenhower/") + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_eisenhower_filter_by_domain( + self, client: AsyncClient, seed_domain: dict, + ): + """Eisenhower page accepts domain_id filter.""" + r = await client.get(f"/eisenhower/?domain_id={seed_domain['id']}") + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_eisenhower_filter_by_status(self, client: AsyncClient): + """Eisenhower page accepts status filter.""" + r = await client.get("/eisenhower/?status=open") + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_eisenhower_combined_filters( + self, client: AsyncClient, seed_domain: dict, seed_project: dict, + ): + """Eisenhower page accepts multiple filters.""" + r = await client.get( + f"/eisenhower/?domain_id={seed_domain['id']}&project_id={seed_project['id']}&status=open" + ) + assert r.status_code == 200 + + +# =========================================================================== +# Focus Filters +# =========================================================================== +class TestFocusFilters: + """Test focus page filter parameters.""" + + @pytest.mark.asyncio + async def test_focus_filter_by_domain( + self, client: AsyncClient, seed_domain: dict, + ): + """Focus page accepts domain_id filter for available tasks.""" + r = await client.get(f"/focus/?domain_id={seed_domain['id']}") + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_focus_filter_by_project( + self, client: AsyncClient, seed_project: dict, + ): + """Focus page accepts project_id filter.""" + r = await client.get(f"/focus/?project_id={seed_project['id']}") + assert r.status_code == 200 + + @pytest.mark.asyncio + async def test_focus_combined_filters( + self, client: AsyncClient, seed_domain: dict, seed_project: dict, + ): + """Focus page accepts multiple filters.""" + r = await client.get( + f"/focus/?domain_id={seed_domain['id']}&project_id={seed_project['id']}" + ) + assert r.status_code == 200 + + +# =========================================================================== +# Standalone Focus Items +# =========================================================================== +class TestStandaloneFocusItems: + """Test quick-add standalone items, edit, and project tab.""" + + @pytest.mark.asyncio + async def test_quick_add_standalone_item( + self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, + ): + """Quick-add creates a standalone focus item with title.""" + tag = _uid() + r = await client.post("/focus/quick-add", data={ + "title": f"Standalone-{tag}", + "quick_domain_id": seed_domain["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT title, domain_id FROM daily_focus WHERE title = :t AND is_deleted = false"), + {"t": f"Standalone-{tag}"}, + ) + row = result.first() + assert row is not None + assert str(row.domain_id) == seed_domain["id"] + + # Cleanup + await db_session.execute( + text("DELETE FROM daily_focus WHERE title = :t"), {"t": f"Standalone-{tag}"} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_quick_add_without_domain( + self, client: AsyncClient, db_session: AsyncSession, + ): + """Quick-add works without a domain.""" + tag = _uid() + r = await client.post("/focus/quick-add", data={ + "title": f"NoDomain-{tag}", + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT title, domain_id FROM daily_focus WHERE title = :t AND is_deleted = false"), + {"t": f"NoDomain-{tag}"}, + ) + row = result.first() + assert row is not None + assert row.domain_id is None + + await db_session.execute( + text("DELETE FROM daily_focus WHERE title = :t"), {"t": f"NoDomain-{tag}"} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_edit_standalone_page_loads( + self, client: AsyncClient, seed_focus_standalone: dict, + ): + """Edit page loads for standalone focus items.""" + r = await client.get(f"/focus/{seed_focus_standalone['id']}/edit") + assert r.status_code == 200 + assert "Test Standalone Focus" in r.text + + @pytest.mark.asyncio + async def test_edit_standalone_redirects_for_task_items( + self, client: AsyncClient, all_seeds: dict, + ): + """Edit page redirects away for task-linked focus items.""" + r = await client.get(f"/focus/{all_seeds['focus']}/edit", follow_redirects=False) + assert r.status_code == 303 + + @pytest.mark.asyncio + async def test_update_standalone_item( + self, client: AsyncClient, db_session: AsyncSession, + seed_domain: dict, seed_project: dict, + ): + """Editing a standalone item updates title, domain, project.""" + tag = _uid() + # Create a standalone item + fid = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO daily_focus (id, focus_date, completed, title, sort_order, is_deleted, created_at, updated_at) " + "VALUES (:id, CURRENT_DATE, false, :title, 0, false, now(), now())"), + {"id": fid, "title": f"EditMe-{tag}"}, + ) + await db_session.commit() + + r = await client.post(f"/focus/{fid}/edit", data={ + "title": f"Edited-{tag}", + "domain_id": seed_domain["id"], + "project_id": seed_project["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT title, domain_id, project_id FROM daily_focus WHERE id = :id"), + {"id": fid}, + ) + row = result.first() + assert row.title == f"Edited-{tag}" + assert str(row.domain_id) == seed_domain["id"] + assert str(row.project_id) == seed_project["id"] + + await db_session.execute(text("DELETE FROM daily_focus WHERE id = :id"), {"id": fid}) + await db_session.commit() + + @pytest.mark.asyncio + async def test_focus_page_shows_standalone_items( + self, client: AsyncClient, seed_focus_standalone: dict, + ): + """Standalone items appear on the focus page.""" + r = await client.get("/focus/", follow_redirects=True) + assert r.status_code == 200 + assert "Test Standalone Focus" in r.text + + @pytest.mark.asyncio + async def test_project_focus_tab( + self, client: AsyncClient, seed_project: dict, seed_focus_standalone: dict, + ): + """Focus tab on project detail shows standalone items assigned to that project.""" + r = await client.get(f"/projects/{seed_project['id']}?tab=focus") + assert r.status_code == 200 + assert "Test Standalone Focus" in r.text + + +# =========================================================================== +# Focus Item Conversion +# =========================================================================== +class TestFocusConversion: + """Test converting standalone focus items to other entity types.""" + + @pytest.mark.asyncio + async def test_convert_to_task( + self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, + ): + """Convert standalone focus item to task.""" + tag = _uid() + fid = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO daily_focus (id, focus_date, completed, title, domain_id, sort_order, is_deleted, created_at, updated_at) " + "VALUES (:id, CURRENT_DATE, false, :title, :did, 0, false, now(), now())"), + {"id": fid, "title": f"ConvTask-{tag}", "did": seed_domain["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/focus/{fid}/convert-to-task", follow_redirects=False) + assert r.status_code == 303 + + # Focus item should now have task_id set and title cleared + result = await db_session.execute( + text("SELECT task_id, title FROM daily_focus WHERE id = :id"), {"id": fid}, + ) + row = result.first() + assert row.task_id is not None + assert row.title is None + + # Task should exist + result = await db_session.execute( + text("SELECT title, status FROM tasks WHERE id = :id"), {"id": str(row.task_id)}, + ) + task = result.first() + assert task.title == f"ConvTask-{tag}" + assert task.status == "open" + + # Cleanup + await db_session.execute(text("DELETE FROM daily_focus WHERE id = :id"), {"id": fid}) + await db_session.execute(text("DELETE FROM tasks WHERE id = :id"), {"id": str(row.task_id)}) + await db_session.commit() + + @pytest.mark.asyncio + async def test_convert_to_note( + self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, + ): + """Convert standalone focus item to note.""" + tag = _uid() + fid = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO daily_focus (id, focus_date, completed, title, domain_id, sort_order, is_deleted, created_at, updated_at) " + "VALUES (:id, CURRENT_DATE, false, :title, :did, 0, false, now(), now())"), + {"id": fid, "title": f"ConvNote-{tag}", "did": seed_domain["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/focus/{fid}/convert-to-note", follow_redirects=False) + assert r.status_code == 303 + + # Focus item should be soft-deleted + result = await db_session.execute( + text("SELECT is_deleted FROM daily_focus WHERE id = :id"), {"id": fid}, + ) + assert result.first().is_deleted is True + + # Note should exist + result = await db_session.execute( + text("SELECT title, domain_id FROM notes WHERE title = :t AND is_deleted = false"), + {"t": f"ConvNote-{tag}"}, + ) + note = result.first() + assert note is not None + assert str(note.domain_id) == seed_domain["id"] + + # Cleanup + await db_session.execute(text("DELETE FROM daily_focus WHERE id = :id"), {"id": fid}) + await db_session.execute( + text("DELETE FROM notes WHERE title = :t"), {"t": f"ConvNote-{tag}"} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_convert_to_link( + self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, + ): + """Convert standalone focus item to link.""" + tag = _uid() + fid = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO daily_focus (id, focus_date, completed, title, domain_id, sort_order, is_deleted, created_at, updated_at) " + "VALUES (:id, CURRENT_DATE, false, :title, :did, 0, false, now(), now())"), + {"id": fid, "title": f"ConvLink-{tag}", "did": seed_domain["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/focus/{fid}/convert-to-link", follow_redirects=False) + assert r.status_code == 303 + + # Focus item should be soft-deleted + result = await db_session.execute( + text("SELECT is_deleted FROM daily_focus WHERE id = :id"), {"id": fid}, + ) + assert result.first().is_deleted is True + + # Link should exist with title as label + result = await db_session.execute( + text("SELECT label, domain_id FROM links WHERE label = :l AND is_deleted = false"), + {"l": f"ConvLink-{tag}"}, + ) + link = result.first() + assert link is not None + assert str(link.domain_id) == seed_domain["id"] + + # Cleanup + await db_session.execute(text("DELETE FROM daily_focus WHERE id = :id"), {"id": fid}) + await db_session.execute( + text("DELETE FROM links WHERE label = :l"), {"l": f"ConvLink-{tag}"} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_convert_to_list_item( + self, client: AsyncClient, db_session: AsyncSession, + seed_domain: dict, seed_list: dict, + ): + """Convert standalone focus item to list item.""" + tag = _uid() + fid = str(uuid.uuid4()) + await db_session.execute( + text("INSERT INTO daily_focus (id, focus_date, completed, title, domain_id, sort_order, is_deleted, created_at, updated_at) " + "VALUES (:id, CURRENT_DATE, false, :title, :did, 0, false, now(), now())"), + {"id": fid, "title": f"ConvLI-{tag}", "did": seed_domain["id"]}, + ) + await db_session.commit() + + r = await client.post(f"/focus/{fid}/convert-to-list-item", data={ + "list_id": seed_list["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + # Focus item should now point to a list_item_id with title cleared + result = await db_session.execute( + text("SELECT list_item_id, title FROM daily_focus WHERE id = :id"), {"id": fid}, + ) + row = result.first() + assert row.list_item_id is not None + assert row.title is None + + # List item should exist + result = await db_session.execute( + text("SELECT content, list_id FROM list_items WHERE id = :id"), + {"id": str(row.list_item_id)}, + ) + li = result.first() + assert li.content == f"ConvLI-{tag}" + assert str(li.list_id) == seed_list["id"] + + # Cleanup + await db_session.execute(text("DELETE FROM daily_focus WHERE id = :id"), {"id": fid}) + await db_session.execute( + text("DELETE FROM list_items WHERE id = :id"), {"id": str(row.list_item_id)} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_convert_nonexistent_item_redirects( + self, client: AsyncClient, + ): + """Converting a nonexistent focus item redirects to /focus.""" + fake_id = str(uuid.uuid4()) + r = await client.post(f"/focus/{fake_id}/convert-to-task", follow_redirects=False) + assert r.status_code == 303 + + @pytest.mark.asyncio + async def test_convert_task_linked_item_redirects( + self, client: AsyncClient, all_seeds: dict, + ): + """Converting a task-linked focus item (no title) redirects without action.""" + r = await client.post(f"/focus/{all_seeds['focus']}/convert-to-note", follow_redirects=False) + assert r.status_code == 303 + + +# =========================================================================== +# Entity Create with task_id/meeting_id +# =========================================================================== +class TestEntityCreateWithParentContext: + """Test creating entities with task_id or meeting_id context.""" + + @pytest.mark.asyncio + async def test_create_note_with_task_id( + self, client: AsyncClient, db_session: AsyncSession, + seed_task: dict, seed_domain: dict, + ): + """Creating a note with task_id sets the FK and redirects to task tab.""" + tag = _uid() + r = await client.post("/notes/create", data={ + "title": f"TaskNote-{tag}", + "domain_id": seed_domain["id"], + "body": "Test body", + "content_format": "markdown", + "task_id": seed_task["id"], + }, follow_redirects=False) + assert r.status_code == 303 + # Should redirect back to task's notes tab + location = r.headers.get("location", "") + assert "tab=notes" in location + + result = await db_session.execute( + text("SELECT task_id FROM notes WHERE title = :t AND is_deleted = false"), + {"t": f"TaskNote-{tag}"}, + ) + row = result.first() + assert row is not None + assert str(row.task_id) == seed_task["id"] + + # Cleanup + await db_session.execute( + text("DELETE FROM notes WHERE title = :t"), {"t": f"TaskNote-{tag}"} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_create_note_form_with_task_prefill( + self, client: AsyncClient, seed_task: dict, + ): + """Note create form with task_id query param loads correctly.""" + r = await client.get(f"/notes/create?task_id={seed_task['id']}") + assert r.status_code == 200 + assert seed_task["id"] in r.text + + @pytest.mark.asyncio + async def test_create_link_with_task_id( + self, client: AsyncClient, db_session: AsyncSession, seed_task: dict, + ): + """Creating a link with task_id sets the FK.""" + tag = _uid() + r = await client.post("/weblinks/create", data={ + "label": f"TaskLink-{tag}", + "url": "https://example.com/test", + "task_id": seed_task["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT task_id FROM links WHERE label = :l AND is_deleted = false"), + {"l": f"TaskLink-{tag}"}, + ) + row = result.first() + assert row is not None + assert str(row.task_id) == seed_task["id"] + + await db_session.execute( + text("DELETE FROM links WHERE label = :l"), {"l": f"TaskLink-{tag}"} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_create_list_with_task_id( + self, client: AsyncClient, db_session: AsyncSession, + seed_task: dict, seed_domain: dict, + ): + """Creating a list with task_id sets the FK.""" + tag = _uid() + r = await client.post("/lists/create", data={ + "name": f"TaskList-{tag}", + "domain_id": seed_domain["id"], + "list_type": "checklist", + "task_id": seed_task["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT task_id FROM lists WHERE name = :n AND is_deleted = false"), + {"n": f"TaskList-{tag}"}, + ) + row = result.first() + assert row is not None + assert str(row.task_id) == seed_task["id"] + + await db_session.execute( + text("DELETE FROM lists WHERE name = :n"), {"n": f"TaskList-{tag}"} + ) + await db_session.commit() + + @pytest.mark.asyncio + async def test_create_decision_with_task_id( + self, client: AsyncClient, db_session: AsyncSession, seed_task: dict, + ): + """Creating a decision with task_id sets the FK.""" + tag = _uid() + r = await client.post("/decisions/create", data={ + "title": f"TaskDecision-{tag}", + "status": "proposed", + "impact": "medium", + "task_id": seed_task["id"], + }, follow_redirects=False) + assert r.status_code == 303 + + result = await db_session.execute( + text("SELECT task_id FROM decisions WHERE title = :t AND is_deleted = false"), + {"t": f"TaskDecision-{tag}"}, + ) + row = result.first() + assert row is not None + assert str(row.task_id) == seed_task["id"] + + await db_session.execute( + text("DELETE FROM decisions WHERE title = :t"), {"t": f"TaskDecision-{tag}"} + ) + await db_session.commit()