feat(stats): optimize dashboard data loading with internal ui endpoints
This commit is contained in:
@@ -55,10 +55,10 @@ def test_stats_returns_html_page_for_empty_board(tmp_path):
|
||||
assert "Backlog" in response.text
|
||||
assert "In Progress" in response.text
|
||||
assert "Done" in response.text
|
||||
assert response.text.count("No tasks") == 3
|
||||
assert response.text.count("Loading tasks...") == 3
|
||||
|
||||
|
||||
def test_stats_renders_tasks_in_all_kanban_columns(tmp_path):
|
||||
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)
|
||||
@@ -70,15 +70,16 @@ def test_stats_renders_tasks_in_all_kanban_columns(tmp_path):
|
||||
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
|
||||
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_preselects_first_task_in_details_block(tmp_path):
|
||||
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)
|
||||
@@ -90,8 +91,8 @@ def test_stats_preselects_first_task_in_details_block(tmp_path):
|
||||
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
|
||||
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):
|
||||
@@ -117,11 +118,11 @@ def test_stats_renders_cycle_time_for_completed_task(tmp_path):
|
||||
|
||||
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]
|
||||
assert "0h 9m" not in response.text
|
||||
assert ">—<" in response.text
|
||||
|
||||
|
||||
def test_stats_embeds_task_payload_for_client_side_switching(tmp_path):
|
||||
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)
|
||||
@@ -129,14 +130,9 @@ def test_stats_embeds_task_payload_for_client_side_switching(tmp_path):
|
||||
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
|
||||
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):
|
||||
@@ -154,7 +150,7 @@ def test_stats_includes_js_hooks_for_dynamic_selected_task_update(tmp_path):
|
||||
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
|
||||
assert "fetch(`/ui_data/tasks/${taskId}`)" in response.text
|
||||
|
||||
|
||||
def test_stats_handles_utf8_titles(tmp_path):
|
||||
@@ -164,7 +160,7 @@ def test_stats_handles_utf8_titles(tmp_path):
|
||||
response = client.get("/stats")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Задача пример" in response.text
|
||||
assert "Задача пример" not in response.text
|
||||
|
||||
|
||||
def test_stats_escapes_html_in_task_title(tmp_path):
|
||||
@@ -175,7 +171,7 @@ def test_stats_escapes_html_in_task_title(tmp_path):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "<script>alert(1)</script>" not in response.text
|
||||
assert "<script>alert(1)</script>" in response.text
|
||||
assert "<script>alert(1)</script>" not in response.text
|
||||
|
||||
|
||||
def test_stats_rejects_unsupported_method(tmp_path):
|
||||
@@ -202,7 +198,7 @@ def test_stats_still_works_after_recovery_from_corrupted_file(tmp_path):
|
||||
assert "No task selected" in response.text
|
||||
|
||||
|
||||
def test_stats_renders_created_started_and_done_values_for_completed_task(tmp_path):
|
||||
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)
|
||||
@@ -210,7 +206,6 @@ def test_stats_renders_created_started_and_done_values_for_completed_task(tmp_pa
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
121
tests/test_api_ui_data.py
Normal file
121
tests/test_api_ui_data.py
Normal file
@@ -0,0 +1,121 @@
|
||||
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_ui_data_board_returns_lightweight_grouped_lists(tmp_path):
|
||||
client, repo = make_client(tmp_path)
|
||||
backlog = make_task(title="Backlog title")
|
||||
in_progress = make_task(title="In progress title", started=True)
|
||||
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("/ui_data/stats/board")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert [item["title"] for item in data["backlog_tasks"]] == ["Backlog title"]
|
||||
assert [item["title"] for item in data["in_progress_tasks"]] == ["In progress title"]
|
||||
assert [item["title"] for item in data["done_tasks"]] == ["Done title"]
|
||||
assert data["backlog_tasks"][0]["display_date_label"] == "Created"
|
||||
assert data["in_progress_tasks"][0]["display_date_label"] == "Started"
|
||||
assert data["done_tasks"][0]["display_date_label"] == "Done"
|
||||
assert "created_at" not in data["backlog_tasks"][0]
|
||||
assert "started_at" not in data["in_progress_tasks"][0]
|
||||
assert "done_at" not in data["done_tasks"][0]
|
||||
assert "cycle_time" not in data["done_tasks"][0]
|
||||
|
||||
|
||||
def test_ui_data_task_details_returns_full_selected_task_data(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(f"/ui_data/tasks/{task.id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["id"] == str(task.id)
|
||||
assert data["title"] == "Detailed task"
|
||||
assert data["status"] == "done"
|
||||
assert datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")) == task.created_at
|
||||
assert datetime.fromisoformat(data["started_at"].replace("Z", "+00:00")) == task.started_at
|
||||
assert datetime.fromisoformat(data["done_at"].replace("Z", "+00:00")) == task.done_at
|
||||
assert data["cycle_time"].startswith("0h 9m")
|
||||
|
||||
|
||||
def test_ui_data_task_details_rejects_invalid_uuid(tmp_path):
|
||||
client, _repo = make_client(tmp_path)
|
||||
|
||||
response = client.get("/ui_data/tasks/bad-id")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"error": "invalid_id",
|
||||
"message": "Task id must be a valid UUID",
|
||||
}
|
||||
|
||||
|
||||
def test_ui_data_task_details_returns_not_found_for_missing_task(tmp_path):
|
||||
client, _repo = make_client(tmp_path)
|
||||
|
||||
response = client.get(f"/ui_data/tasks/{uuid4()}")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"error": "invalid_id",
|
||||
"message": "Task was not found",
|
||||
}
|
||||
|
||||
|
||||
def test_ui_data_routes_are_hidden_from_openapi_schema(tmp_path):
|
||||
client, _repo = make_client(tmp_path)
|
||||
|
||||
response = client.get("/openapi.json")
|
||||
|
||||
assert response.status_code == 200
|
||||
paths = response.json()["paths"]
|
||||
assert "/ui_data/stats/board" not in paths
|
||||
assert "/ui_data/tasks/{task_id}" not in paths
|
||||
Reference in New Issue
Block a user