""" Business Logic Tests ==================== Hand-written tests for specific behavioral contracts. These test LOGIC, not routes, so they stay manual. When to add tests here: - New constraint (e.g. "only one timer running at a time") - State transitions (e.g. "completing a task sets completed_at") - Cross-entity effects (e.g. "deleting a project hides its tasks") - Search behavior - Sidebar data integrity """ from __future__ import annotations import uuid from datetime import date, datetime, timezone import pytest from httpx import AsyncClient from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession # =========================================================================== # Time Tracking # =========================================================================== class TestTimerConstraints: """Only one timer can run at a time. Starting a new one auto-stops the old.""" @pytest.mark.asyncio async def test_single_timer_constraint( self, client: AsyncClient, db_session: AsyncSession, seed_domain: dict, seed_project: dict, ): t1 = await _create_task(db_session, seed_domain["id"], seed_project["id"], "Timer1") t2 = await _create_task(db_session, seed_domain["id"], seed_project["id"], "Timer2") await client.post("/time/start", data={"task_id": t1}, follow_redirects=False) await client.post("/time/start", data={"task_id": t2}, follow_redirects=False) result = await db_session.execute( text("SELECT count(*) FROM time_entries WHERE end_at IS NULL") ) assert result.scalar() <= 1 @pytest.mark.asyncio async def test_stop_sets_end_at( self, client: AsyncClient, db_session: AsyncSession, seed_task: dict, ): await client.post("/time/start", data={"task_id": seed_task["id"]}, follow_redirects=False) await client.post("/time/stop", follow_redirects=False) result = await db_session.execute( text("SELECT count(*) FROM time_entries WHERE end_at IS NULL AND task_id = :tid"), {"tid": seed_task["id"]}, ) assert result.scalar() == 0 @pytest.mark.asyncio async def test_running_endpoint_returns_json( self, client: AsyncClient, seed_task: dict, ): await client.post("/time/start", data={"task_id": seed_task["id"]}, follow_redirects=False) r = await client.get("/time/running") assert r.status_code == 200 # Should be valid JSON data = r.json() assert data is not None # =========================================================================== # Soft Delete & Restore # =========================================================================== class TestSoftDeleteBehavior: """Soft-deleted items should vanish from lists and reappear after restore.""" @pytest.mark.asyncio async def test_deleted_task_hidden_from_list( self, client: AsyncClient, seed_task: dict, ): await client.post(f"/tasks/{seed_task['id']}/delete", follow_redirects=False) r = await client.get("/tasks/") assert seed_task["title"] not in r.text @pytest.mark.asyncio async def test_restore_task_reappears( self, client: AsyncClient, seed_task: dict, ): await client.post(f"/tasks/{seed_task['id']}/delete", follow_redirects=False) await client.post( f"/admin/trash/restore/tasks/{seed_task['id']}", follow_redirects=False, ) r = await client.get("/tasks/") assert seed_task["title"] in r.text @pytest.mark.asyncio async def test_deleted_project_hidden( self, client: AsyncClient, seed_project: dict, ): await client.post(f"/projects/{seed_project['id']}/delete", follow_redirects=False) r = await client.get("/projects/") assert seed_project["name"] not in r.text # =========================================================================== # Search # =========================================================================== class TestSearchBehavior: @pytest.mark.asyncio async def test_search_does_not_crash_on_sql_injection(self, client: AsyncClient): r = await client.get("/search/?q='; DROP TABLE tasks; --") assert r.status_code == 200 @pytest.mark.asyncio async def test_search_empty_query(self, client: AsyncClient): r = await client.get("/search/?q=") assert r.status_code == 200 @pytest.mark.asyncio async def test_search_special_unicode(self, client: AsyncClient): r = await client.get("/search/?q=日本語テスト") assert r.status_code == 200 # =========================================================================== # Sidebar # =========================================================================== class TestSidebarIntegrity: @pytest.mark.asyncio async def test_sidebar_shows_domain_on_every_page( self, client: AsyncClient, seed_domain: dict, ): """Domain should appear in sidebar across all pages.""" # Sample a few different page types for path in ("/", "/tasks/", "/notes/", "/projects/"): r = await client.get(path) assert seed_domain["name"] in r.text, f"Domain missing from sidebar on {path}" @pytest.mark.asyncio async def test_sidebar_shows_project_hierarchy( self, client: AsyncClient, seed_domain: dict, seed_area: dict, seed_project: dict, ): r = await client.get("/") assert seed_project["name"] in r.text # =========================================================================== # Focus & Capture Workflows # =========================================================================== class TestFocusWorkflow: @pytest.mark.asyncio async def test_add_and_remove_from_focus( self, client: AsyncClient, db_session: AsyncSession, seed_task: dict, ): # Add to focus r = await client.post("/focus/add", data={"task_id": seed_task["id"]}, follow_redirects=False) assert r.status_code in (303, 302) @pytest.mark.asyncio async def test_capture_multi_line_creates_multiple( self, client: AsyncClient, db_session: AsyncSession, ): await client.post( "/capture/add", data={"raw_text": "Line one\nLine two\nLine three"}, follow_redirects=False, ) result = await db_session.execute( text("SELECT count(*) FROM capture WHERE is_deleted = false") ) count = result.scalar() # Should have created at least 2 items (3 lines) assert count >= 2, f"Expected multiple capture items, got {count}" # =========================================================================== # Edge Cases # =========================================================================== class TestEdgeCases: @pytest.mark.asyncio async def test_invalid_uuid_in_path(self, client: AsyncClient): r = await client.get("/tasks/not-a-valid-uuid") assert r.status_code in (404, 422, 400) @pytest.mark.asyncio async def test_timer_start_without_task_id(self, client: AsyncClient): r = await client.post("/time/start", data={}, follow_redirects=False) assert r.status_code != 200 # Should error, not silently succeed @pytest.mark.asyncio async def test_double_delete_doesnt_crash( self, client: AsyncClient, seed_task: dict, ): await client.post(f"/tasks/{seed_task['id']}/delete", follow_redirects=False) r = await client.post(f"/tasks/{seed_task['id']}/delete", follow_redirects=False) assert r.status_code in (303, 302, 404) # =========================================================================== # Helpers # =========================================================================== async def _create_task(db: AsyncSession, domain_id: str, project_id: str, title: str) -> str: _id = str(uuid.uuid4()) await db.execute( text("INSERT INTO tasks (id, domain_id, project_id, title, status, priority, sort_order, is_deleted, created_at, updated_at) " "VALUES (:id, :did, :pid, :title, 'open', 3, 0, false, now(), now())"), {"id": _id, "did": domain_id, "pid": project_id, "title": title}, ) await db.flush() return _id