feat(taskflow): add core task API, storage persistence, csv export, stats page, and test coverage

This commit is contained in:
Alexander Kalinovsky
2026-04-01 17:56:03 +03:00
commit 19d659df6b
31 changed files with 4197 additions and 0 deletions

502
tests/test_api_tasks.py Normal file
View File

@@ -0,0 +1,502 @@
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")