diff --git a/.gitignore b/.gitignore index 61ec848..dc4ce8d 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ __pycache__/ .venv/ uploads/ test_*.py +!tests/test_*.py venv/ *.egg-info/ dist/ diff --git a/app/templates/dashboard/ceo.html b/app/templates/dashboard/ceo.html index cc06113..35aa1b9 100644 --- a/app/templates/dashboard/ceo.html +++ b/app/templates/dashboard/ceo.html @@ -171,10 +171,22 @@ async loadData() { try { - const allData = await app().apiGet('/api/tickets?page_size=500'); - if (!allData?.items) return; - const all = allData.items; - const total = allData.total || all.length; + // Fetch ALL tickets via pagination. The API caps page_size at 200 + // (app/routers/tickets.py), so a single page_size=500 request returns 422 + // and the dashboard renders empty KPIs. Loop pages until we have `total` + // 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 const open = all.filter(t => !['Closed', 'Completed'].includes(t.status)); diff --git a/pyproject.toml b/pyproject.toml index 224e9ea..641e0a7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,10 @@ build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] include = ["app*"] +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + [project.optional-dependencies] dev = [ "pytest>=8.0", diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b25ca30 --- /dev/null +++ b/tests/conftest.py @@ -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 diff --git a/tests/test_tickets_pagination.py b/tests/test_tickets_pagination.py new file mode 100644 index 0000000..5069193 --- /dev/null +++ b/tests/test_tickets_pagination.py @@ -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)