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("Loading tasks...") == 3 def test_stats_includes_client_side_board_loading_hooks(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" in response.text assert "In Progress" in response.text assert "Done" in response.text assert 'id="backlog-list"' in response.text assert 'id="in-progress-list"' in response.text assert 'id="done-list"' in response.text assert 'fetch("/ui_data/stats/board")' in response.text def test_stats_renders_empty_details_block_until_client_side_fetch(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 "No task selected" in response.text assert first.created_at.isoformat() not 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" not in response.text assert ">—<" in response.text def test_stats_does_not_embed_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='" not in response.text assert str(task.id) not in response.text assert "Payload task" not 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 "fetch(`/ui_data/tasks/${taskId}`)" 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 "Задача пример" not 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>" not 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_does_not_render_created_started_and_done_values_server_side(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() not in response.text assert task.started_at.isoformat() not in response.text assert task.done_at.isoformat() not in response.text