feat(taskflow): add core task API, storage persistence, csv export, stats page, and test coverage
This commit is contained in:
502
tests/test_api_tasks.py
Normal file
502
tests/test_api_tasks.py
Normal 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")
|
||||
|
||||
Reference in New Issue
Block a user