213 lines
8.2 KiB
Python
213 lines
8.2 KiB
Python
"""
|
|
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
|