from __future__ import annotations
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_stats_returns_html_page_for_empty_board(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/stats")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "TaskFlow Stats" in response.text
assert "No task selected" in response.text
assert "Backlog" in response.text
assert "In Progress" in response.text
assert "Done" in response.text
assert response.text.count("No tasks") == 3
def test_stats_renders_tasks_in_all_kanban_columns(tmp_path):
client, repo = make_client(tmp_path)
backlog = make_task(title="Backlog title", started=False, done=False)
in_progress = make_task(title="In progress title", started=True, done=False)
done = make_task(title="Done title", started=True, done=True)
repo.create_task(backlog)
repo.create_task(in_progress)
repo.create_task(done)
response = client.get("/stats")
assert response.status_code == 200
assert "Backlog title" in response.text
assert "In progress title" in response.text
assert "Done title" in response.text
assert "Backlog" in response.text
assert "In Progress" in response.text
assert "Done" in response.text
def test_stats_preselects_first_task_in_details_block(tmp_path):
client, repo = make_client(tmp_path)
first = make_task(title="First task", started=False, done=False)
second = make_task(title="Second task", started=True, done=False)
repo.create_task(first)
repo.create_task(second)
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-title"' in response.text
assert 'id="selected-created-at"' in response.text
assert "First task" in response.text
assert first.created_at.isoformat() in response.text
def test_stats_shows_placeholders_for_missing_dates(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Backlog only", started=False, done=False)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-started-at"' in response.text
assert 'id="selected-done-at"' in response.text
assert 'id="selected-cycle-time"' in response.text
assert ">—<" in response.text
def test_stats_renders_cycle_time_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Completed task", started=True, done=True)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-cycle-time"' in response.text
assert "0h 9m" in response.text
assert ">—<" not in response.text.split('id="selected-cycle-time"', 1)[1].split("", 2)[1]
def test_stats_embeds_task_payload_for_client_side_switching(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Payload task", started=True, done=True)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert "data-task='" in response.text
assert str(task.id) in response.text
assert '"title": "Payload task"' in response.text
assert '"status": "done"' in response.text
assert '"created_at":' in response.text
assert '"started_at":' in response.text
assert '"done_at":' in response.text
assert '"cycle_time":' in response.text
def test_stats_includes_js_hooks_for_dynamic_selected_task_update(tmp_path):
client, repo = make_client(tmp_path)
repo.create_task(make_task(title="Clickable"))
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-title"' in response.text
assert 'id="selected-status"' in response.text
assert 'id="selected-started-at"' in response.text
assert 'id="selected-done-at"' in response.text
assert 'id="selected-cycle-time"' in response.text
assert 'id="selected-created-at"' in response.text
assert 'document.querySelectorAll(".task-card")' in response.text
assert 'card.addEventListener("click"' in response.text
assert "JSON.parse(card.dataset.task)" in response.text
def test_stats_handles_utf8_titles(tmp_path):
client, repo = make_client(tmp_path)
repo.create_task(make_task(title="Задача пример"))
response = client.get("/stats")
assert response.status_code == 200
assert "Задача пример" in response.text
def test_stats_escapes_html_in_task_title(tmp_path):
client, repo = make_client(tmp_path)
repo.create_task(make_task(title=""))
response = client.get("/stats")
assert response.status_code == 200
assert "" not in response.text
assert "<script>alert(1)</script>" in response.text
def test_stats_rejects_unsupported_method(tmp_path):
client, _repo = make_client(tmp_path)
response = client.post("/stats")
assert response.status_code == 405
def test_stats_still_works_after_recovery_from_corrupted_file(tmp_path):
file_path = tmp_path / "tasks.json"
file_path.write_text("{broken", encoding="utf-8")
repo = JsonFileTaskRepository(file_path)
app.dependency_overrides[get_task_repository] = lambda: repo
client = TestClient(app)
response = client.get("/stats")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "TaskFlow Stats" in response.text
assert "No task selected" in response.text
def test_stats_renders_created_started_and_done_values_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Detailed task", started=True, done=True)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert task.created_at.isoformat() in response.text
assert task.started_at.isoformat() in response.text
assert task.done_at.isoformat() in response.text