from __future__ import annotations import csv from datetime import UTC, datetime, timedelta from uuid import uuid4 from fastapi.testclient import TestClient from app.api.tasks import get_task_repository from app.main import app from app.storage import JsonFileTaskRepository, StoredTask def make_repo(tmp_path) -> JsonFileTaskRepository: return JsonFileTaskRepository(tmp_path / "tasks.json") def make_task( *, title: str = "Task", started: bool = False, done: bool = False, ) -> StoredTask: now = datetime.now(UTC) started_at = now - timedelta(minutes=10) if started else None done_at = now - timedelta(minutes=1) if done else None return StoredTask( id=uuid4(), title=title, created_at=now - timedelta(hours=1), started_at=started_at, done_at=done_at, ) def make_client(tmp_path) -> tuple[TestClient, JsonFileTaskRepository]: repo = make_repo(tmp_path) app.dependency_overrides[get_task_repository] = lambda: repo client = TestClient(app) return client, repo def teardown_function() -> None: app.dependency_overrides.clear() def test_list_tasks_returns_empty_list_by_default(tmp_path): client, _repo = make_client(tmp_path) response = client.get("/api/tasks") assert response.status_code == 200 assert response.json() == { "items": [], "total": 0, "limit": 100, "offset": 0, } def test_list_tasks_filters_by_status_and_search(tmp_path): client, repo = make_client(tmp_path) backlog = make_task(title="Alpha backlog") in_progress = make_task(title="Alpha in progress", started=True, done=False) done = make_task(title="Beta done", started=True, done=True) repo.create_task(backlog) repo.create_task(in_progress) repo.create_task(done) response = client.get( "/api/tasks", params={"status": "in_progress", "search": "alpha"}, ) assert response.status_code == 200 data = response.json() assert data["total"] == 1 assert len(data["items"]) == 1 assert data["items"][0]["id"] == str(in_progress.id) assert data["items"][0]["status"] == "in_progress" def test_list_tasks_rejects_invalid_status_filter(tmp_path): client, _repo = make_client(tmp_path) response = client.get("/api/tasks", params={"status": "unknown"}) assert response.status_code == 400 assert response.json()["error"] == "invalid_payload" def test_get_task_returns_existing_task(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Lookup task") repo.create_task(task) response = client.get(f"/api/tasks/{task.id}") assert response.status_code == 200 data = response.json() assert data["id"] == str(task.id) assert data["title"] == task.title assert data["status"] == "backlog" def test_get_task_returns_done_status_for_completed_task(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Completed", started=True, done=True) repo.create_task(task) response = client.get(f"/api/tasks/{task.id}") assert response.status_code == 200 assert response.json()["status"] == "done" def test_get_task_returns_invalid_id_for_bad_uuid(tmp_path): client, _repo = make_client(tmp_path) response = client.get("/api/tasks/bad-id") assert response.status_code == 400 assert response.json() == { "error": "invalid_id", "message": "Task id must be a valid UUID", } def test_create_task_creates_new_backlog_task(tmp_path): client, repo = make_client(tmp_path) response = client.post("/api/tasks", json={"title": "Test task"}) assert response.status_code == 201 data = response.json() assert data["title"] == "Test task" assert data["started_at"] is None assert data["done_at"] is None assert data["status"] == "backlog" assert repo.get_task(uuid4()) is None assert len(repo.list_tasks()) == 1 def test_create_task_trims_title(tmp_path): client, repo = make_client(tmp_path) response = client.post("/api/tasks", json={"title": " Trim me "}) assert response.status_code == 201 data = response.json() assert data["title"] == "Trim me" assert repo.list_tasks()[0].title == "Trim me" def test_create_task_rejects_invalid_payload(tmp_path): client, _repo = make_client(tmp_path) response = client.post("/api/tasks", json={"title": ""}) assert response.status_code == 422 def test_update_task_updates_title(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Old title") repo.create_task(task) response = client.patch( f"/api/tasks/{task.id}", json={"title": "Updated title"}, ) assert response.status_code == 200 data = response.json() assert data["title"] == "Updated title" reloaded = repo.get_task(task.id) assert reloaded is not None assert reloaded.title == "Updated title" assert reloaded.created_at == task.created_at def test_update_task_keeps_existing_timestamps(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Workflow", started=True, done=True) repo.create_task(task) response = client.patch( f"/api/tasks/{task.id}", json={"title": "Workflow updated"}, ) assert response.status_code == 200 data = response.json() assert data["title"] == "Workflow updated" assert datetime.fromisoformat(data["started_at"]) == task.started_at assert datetime.fromisoformat(data["done_at"]) == task.done_at def test_update_task_returns_not_found_for_missing_task(tmp_path): client, _repo = make_client(tmp_path) response = client.patch( f"/api/tasks/{uuid4()}", json={"title": "Updated"}, ) assert response.status_code == 404 assert response.json()["error"] == "invalid_id" def test_delete_task_removes_existing_task(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Delete me") repo.create_task(task) response = client.delete(f"/api/tasks/{task.id}") assert response.status_code == 204 assert repo.get_task(task.id) is None def test_delete_task_only_removes_target_task(tmp_path): client, repo = make_client(tmp_path) task_1 = make_task(title="Keep me") task_2 = make_task(title="Delete me") repo.create_task(task_1) repo.create_task(task_2) response = client.delete(f"/api/tasks/{task_2.id}") assert response.status_code == 204 remaining = repo.list_tasks() assert remaining == [task_1] def test_delete_task_returns_invalid_id_for_bad_uuid(tmp_path): client, _repo = make_client(tmp_path) response = client.delete("/api/tasks/bad-id") assert response.status_code == 400 assert response.json()["error"] == "invalid_id" def test_start_task_transitions_backlog_to_in_progress(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Start me") repo.create_task(task) before_call = datetime.now(UTC) response = client.post(f"/api/tasks/{task.id}/start") after_call = datetime.now(UTC) assert response.status_code == 200 data = response.json() assert data["status"] == "in_progress" assert data["done_at"] is None started_at = datetime.fromisoformat(data["started_at"]) assert before_call <= started_at <= after_call def test_start_task_is_idempotent(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Already started", started=True, done=False) repo.create_task(task) response = client.post(f"/api/tasks/{task.id}/start") assert response.status_code == 200 data = response.json() assert data["status"] == "in_progress" assert datetime.fromisoformat(data["started_at"]) == task.started_at def test_start_task_rejects_completed_task(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Completed", started=True, done=True) repo.create_task(task) response = client.post(f"/api/tasks/{task.id}/start") assert response.status_code == 409 assert response.json()["error"] == "invalid_transaction" def test_done_task_transitions_in_progress_to_done(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Complete me", started=True, done=False) repo.create_task(task) before_call = datetime.now(UTC) response = client.post(f"/api/tasks/{task.id}/done") after_call = datetime.now(UTC) assert response.status_code == 200 data = response.json() assert data["status"] == "done" assert datetime.fromisoformat(data["started_at"]) == task.started_at done_at = datetime.fromisoformat(data["done_at"]) assert before_call <= done_at <= after_call assert done_at >= task.started_at def test_done_task_is_idempotent_for_completed_task(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Already done", started=True, done=True) repo.create_task(task) response = client.post(f"/api/tasks/{task.id}/done") assert response.status_code == 200 data = response.json() assert data["status"] == "done" assert datetime.fromisoformat(data["done_at"]) == task.done_at def test_done_task_rejects_backlog_task(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Backlog") repo.create_task(task) response = client.post(f"/api/tasks/{task.id}/done") assert response.status_code == 409 assert response.json()["error"] == "invalid_transaction" def test_list_tasks_rejects_invalid_limit(tmp_path): client, _repo = make_client(tmp_path) response = client.get("/api/tasks", params={"limit": 0}) assert response.status_code == 422 def test_get_task_returns_not_found_for_missing_uuid(tmp_path): client, _repo = make_client(tmp_path) response = client.get(f"/api/tasks/{uuid4()}") assert response.status_code == 404 assert response.json()["error"] == "invalid_id" def test_patch_task_rejects_invalid_payload(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Patch me") repo.create_task(task) response = client.patch( f"/api/tasks/{task.id}", json={"title": ""}, ) assert response.status_code == 422 def test_delete_task_returns_not_found_for_missing_uuid(tmp_path): client, _repo = make_client(tmp_path) response = client.delete(f"/api/tasks/{uuid4()}") assert response.status_code == 404 assert response.json()["error"] == "invalid_id" def test_start_task_returns_not_found_for_missing_uuid(tmp_path): client, _repo = make_client(tmp_path) response = client.post(f"/api/tasks/{uuid4()}/start") assert response.status_code == 404 assert response.json()["error"] == "invalid_id" def test_done_task_returns_not_found_for_missing_uuid(tmp_path): client, _repo = make_client(tmp_path) response = client.post(f"/api/tasks/{uuid4()}/done") assert response.status_code == 404 assert response.json()["error"] == "invalid_id" def test_export_tasks_csv_returns_header_only_for_empty_storage(tmp_path): client, _repo = make_client(tmp_path) response = client.get("/api/tasks/export") assert response.status_code == 200 assert response.headers["content-type"].startswith("text/csv") assert response.headers["content-disposition"] == 'attachment; filename="tasks.csv"' assert response.text == "id,title,status,created_at,started_at,done_at\n" def test_export_tasks_csv_returns_rows_for_multiple_statuses(tmp_path): client, repo = make_client(tmp_path) backlog = make_task(title="Backlog task", started=False, done=False) in_progress = make_task(title="In progress task", started=True, done=False) done = make_task(title="Done task", started=True, done=True) repo.create_task(backlog) repo.create_task(in_progress) repo.create_task(done) response = client.get("/api/tasks/export") assert response.status_code == 200 assert response.headers["content-type"].startswith("text/csv") rows = list(csv.DictReader(response.text.splitlines())) assert len(rows) == 3 by_id = {row["id"]: row for row in rows} assert by_id[str(backlog.id)]["title"] == "Backlog task" assert by_id[str(backlog.id)]["status"] == "backlog" assert by_id[str(backlog.id)]["started_at"] == "" assert by_id[str(backlog.id)]["done_at"] == "" assert by_id[str(in_progress.id)]["title"] == "In progress task" assert by_id[str(in_progress.id)]["status"] == "in_progress" assert datetime.fromisoformat(by_id[str(in_progress.id)]["created_at"]) == in_progress.created_at assert datetime.fromisoformat(by_id[str(in_progress.id)]["started_at"]) == in_progress.started_at assert by_id[str(in_progress.id)]["done_at"] == "" assert by_id[str(done.id)]["title"] == "Done task" assert by_id[str(done.id)]["status"] == "done" assert datetime.fromisoformat(by_id[str(done.id)]["created_at"]) == done.created_at assert datetime.fromisoformat(by_id[str(done.id)]["started_at"]) == done.started_at assert datetime.fromisoformat(by_id[str(done.id)]["done_at"]) == done.done_at def test_export_tasks_csv_protects_against_csv_injection(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="=2+2") repo.create_task(task) response = client.get("/api/tasks/export") assert response.status_code == 200 rows = list(csv.DictReader(response.text.splitlines())) assert len(rows) == 1 assert rows[0]["title"] == "'=2+2" def test_export_tasks_csv_preserves_utf8_titles(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Задача пример") repo.create_task(task) response = client.get("/api/tasks/export") assert response.status_code == 200 assert response.headers["content-type"].startswith("text/csv") assert "Задача пример" in response.text def test_export_tasks_csv_uses_empty_strings_for_absent_timestamps(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="No timestamps", started=False, done=False) repo.create_task(task) response = client.get("/api/tasks/export") assert response.status_code == 200 rows = list(csv.DictReader(response.text.splitlines())) assert len(rows) == 1 assert rows[0]["started_at"] == "" assert rows[0]["done_at"] == "" def test_export_tasks_csv_is_stable_across_repeated_calls(tmp_path): client, repo = make_client(tmp_path) task = make_task(title="Stable export", started=True, done=True) repo.create_task(task) response_1 = client.get("/api/tasks/export") response_2 = client.get("/api/tasks/export") assert response_1.status_code == 200 assert response_2.status_code == 200 assert response_1.headers["content-type"].startswith("text/csv") assert response_2.headers["content-type"].startswith("text/csv") assert response_1.text == response_2.text def test_export_tasks_csv_rejects_unsupported_method(tmp_path): client, _repo = make_client(tmp_path) response = client.post("/api/tasks/export") assert response.status_code == 405 def test_export_route_is_not_shadowed_by_task_id_route(tmp_path): client, _repo = make_client(tmp_path) response = client.get("/api/tasks/export") assert response.status_code == 200 assert response.headers["content-type"].startswith("text/csv")