fix(dashboard): paginate CEO dashboard ticket fetch to respect API cap
The CEO dashboard requested /api/tickets?page_size=500, but the API caps page_size at 200 (le=200 in app/routers/tickets.py), so the request returned 422 and every KPI/chart rendered zeros. - ceo.html: fetch all tickets by looping pages of page_size=200 until total items are collected (with a safety bound), keeping KPIs accurate as volume grows past 200. - tests: add test suite anchoring the pagination contract — page_size=500 returns 422, page_size=200 returns items/total/page/page_size, and a page loop collects every ticket without duplicates. - pyproject: enable pytest-asyncio auto mode and tests/ discovery. - .gitignore: un-ignore committed tests/test_*.py.
This commit is contained in:
@@ -6,6 +6,7 @@ __pycache__/
|
|||||||
.venv/
|
.venv/
|
||||||
uploads/
|
uploads/
|
||||||
test_*.py
|
test_*.py
|
||||||
|
!tests/test_*.py
|
||||||
venv/
|
venv/
|
||||||
*.egg-info/
|
*.egg-info/
|
||||||
dist/
|
dist/
|
||||||
|
|||||||
@@ -171,10 +171,22 @@
|
|||||||
|
|
||||||
async loadData() {
|
async loadData() {
|
||||||
try {
|
try {
|
||||||
const allData = await app().apiGet('/api/tickets?page_size=500');
|
// Fetch ALL tickets via pagination. The API caps page_size at 200
|
||||||
if (!allData?.items) return;
|
// (app/routers/tickets.py), so a single page_size=500 request returns 422
|
||||||
const all = allData.items;
|
// and the dashboard renders empty KPIs. Loop pages until we have `total`
|
||||||
const total = allData.total || all.length;
|
// tickets so KPIs stay accurate as volume grows past 200.
|
||||||
|
const all = [];
|
||||||
|
const pageSize = 200;
|
||||||
|
let page = 1;
|
||||||
|
let total = Infinity;
|
||||||
|
while (all.length < total && page <= 1000) { // 1000-page safety bound
|
||||||
|
const allData = await app().apiGet(`/api/tickets?page=${page}&page_size=${pageSize}`);
|
||||||
|
if (!allData?.items || !allData.items.length) break;
|
||||||
|
all.push(...allData.items);
|
||||||
|
total = allData.total ?? all.length;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
if (!all.length) return;
|
||||||
|
|
||||||
// Basic KPIs
|
// Basic KPIs
|
||||||
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
const open = all.filter(t => !['Closed', 'Completed'].includes(t.status));
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ build-backend = "setuptools.build_meta"
|
|||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["app*"]
|
include = ["app*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=8.0",
|
"pytest>=8.0",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Shared fixtures for the Denya OneCare test suite.
|
||||||
|
|
||||||
|
Sets DATABASE_URL to an isolated temp SQLite file BEFORE importing any app
|
||||||
|
module (the engine is created at import time), then provisions tables and
|
||||||
|
seed data per test.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_TMP_DIR = tempfile.mkdtemp(prefix="denya-test-")
|
||||||
|
os.environ["DATABASE_URL"] = f"sqlite+aiosqlite:///{_TMP_DIR}/test.db"
|
||||||
|
|
||||||
|
import pytest_asyncio
|
||||||
|
from httpx import ASGITransport, AsyncClient
|
||||||
|
|
||||||
|
from app.core.database import Base, async_session_factory, engine
|
||||||
|
from app.main import app
|
||||||
|
from app.models.ticket import Ticket
|
||||||
|
from app.services.seed import seed_categories, seed_units, seed_users
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def client():
|
||||||
|
"""Async test client with a fresh, seeded database per test."""
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
await seed_users(session)
|
||||||
|
await session.commit()
|
||||||
|
# json_path=None → built-in fallback units (apartment_mapping.json is not committed)
|
||||||
|
await seed_units(session, json_path=None)
|
||||||
|
await session.commit()
|
||||||
|
await seed_categories(session)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
transport = ASGITransport(app=app)
|
||||||
|
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||||
|
yield c
|
||||||
|
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.drop_all)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def seed_tickets():
|
||||||
|
"""Insert `n` tickets directly into the DB; returns the count inserted."""
|
||||||
|
|
||||||
|
async def _seed(n: int) -> int:
|
||||||
|
async with async_session_factory() as session:
|
||||||
|
for i in range(n):
|
||||||
|
session.add(
|
||||||
|
Ticket(
|
||||||
|
ticket_number=f"PAV-TEST-{i:05d}",
|
||||||
|
status="Logged",
|
||||||
|
priority="medium",
|
||||||
|
description=f"Test ticket {i}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return n
|
||||||
|
|
||||||
|
return _seed
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests anchoring ticket list pagination behavior.
|
||||||
|
|
||||||
|
The CEO dashboard (app/templates/dashboard/ceo.html) previously requested
|
||||||
|
`page_size=500`; the API caps page_size at 200 (`le=200` in
|
||||||
|
app/routers/tickets.py), so that request returned 422 and the dashboard
|
||||||
|
rendered empty KPIs. These tests pin the API contract the frontend now
|
||||||
|
relies on: page_size=200 + page loops that collect every ticket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
|
|
||||||
|
async def test_page_size_over_cap_returns_422(client):
|
||||||
|
"""Requests above the page_size cap must be rejected (the original bug)."""
|
||||||
|
resp = await client.get("/api/tickets", params={"page_size": 500})
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
async def test_page_size_at_cap_returns_items_and_total(client, seed_tickets):
|
||||||
|
"""page_size=200 is the max legal value and returns the full response shape."""
|
||||||
|
await seed_tickets(14)
|
||||||
|
resp = await client.get("/api/tickets", params={"page": 1, "page_size": 200})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["total"] == 14
|
||||||
|
assert len(data["items"]) == 14
|
||||||
|
assert data["page"] == 1
|
||||||
|
assert data["page_size"] == 200
|
||||||
|
|
||||||
|
|
||||||
|
async def test_paginated_loop_collects_all_tickets(client, seed_tickets):
|
||||||
|
"""The frontend's page loop (page_size=200 until total reached) collects every ticket."""
|
||||||
|
total_seeded = await seed_tickets(450) # 3 pages of 200
|
||||||
|
|
||||||
|
collected: list[dict] = []
|
||||||
|
total = float("inf")
|
||||||
|
page = 1
|
||||||
|
page_size = 200
|
||||||
|
while len(collected) < total and page <= 1000:
|
||||||
|
resp = await client.get("/api/tickets", params={"page": page, "page_size": page_size})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["items"], "expected a non-empty page"
|
||||||
|
collected.extend(data["items"])
|
||||||
|
total = data["total"] or len(collected)
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
assert total == total_seeded
|
||||||
|
assert len(collected) == total_seeded
|
||||||
|
# No duplicate tickets across pages
|
||||||
|
ids = [t["id"] for t in collected]
|
||||||
|
assert len(set(ids)) == len(ids)
|
||||||
Reference in New Issue
Block a user