Commit prior to Claude Code implementation on VM
This commit is contained in:
212
tests/test_business_logic.py
Normal file
212
tests/test_business_logic.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user