217 lines
7.0 KiB
Python
217 lines
7.0 KiB
Python
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("</div>", 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="<script>alert(1)</script>"))
|
|
|
|
response = client.get("/stats")
|
|
|
|
assert response.status_code == 200
|
|
assert "<script>alert(1)</script>" 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
|
|
|