""" Focused tests for uncovered API areas in Canteen Asset Tracker. Covers: geofence point check, proximity search, service-summary export, settings models CRUD, and auth-aware smoke test helpers. """ import os import sys from pathlib import Path import pytest from fastapi.testclient import TestClient # ─── Test DB setup ──────────────────────────────────────────────────────── TEST_DB = Path(__file__).parent / "test_gap_coverage.db" os.environ["CANTEEN_DB_PATH"] = str(TEST_DB) os.environ["CANTEEN_SKIP_AUTH"] = "1" def _clean_db(): for suffix in ("", "-shm", "-wal", "-journal"): p = TEST_DB.with_suffix(TEST_DB.suffix + suffix) if p.exists(): p.unlink() @pytest.fixture(autouse=True) def clean_db(): _clean_db() yield _clean_db() @pytest.fixture def client(): _clean_db() for mod in list(sys.modules.keys()): if mod == "server" or mod.startswith("server."): del sys.modules[mod] import server import importlib importlib.invalidate_caches() with TestClient(server.app) as tc: yield tc # ─── Auth helpers ────────────────────────────────────────────────────────── def login(client, username="admin", password="changeme"): """Login and return the auth header dict.""" r = client.post("/api/auth/login", json={"username": username, "password": password}) if r.status_code != 200: pytest.skip(f"Login failed ({r.status_code}): {r.text}") token = r.json()["token"] return {"Authorization": f"Bearer {token}"} # ═══════════════════════════════════════════════════════════════════════════ # 1. Geofence point check # ═══════════════════════════════════════════════════════════════════════════ class TestGeofencePointCheck: """/api/geofences/check — test if a point is inside a geofence polygon.""" def _create_square_geofence(self, client, name, lat=0, lng=0, size=1): """Helper: create a square geofence centered at (lat, lng).""" return client.post("/api/geofences", json={ "name": name, "points": [ {"lat": lat - size, "lng": lng - size}, {"lat": lat - size, "lng": lng + size}, {"lat": lat + size, "lng": lng + size}, {"lat": lat + size, "lng": lng - size}, ], "color": "#ff0000", }) def test_check_inside_polygon(self, client): """Point clearly inside a simple square geofence.""" self._create_square_geofence(client, "Square", lat=40, lng=-74, size=1) r = client.post("/api/geofences/check", json={"lat": 40, "lng": -74}) assert r.status_code == 200 data = r.json() assert isinstance(data, list) names = [g["name"] for g in data] assert "Square" in names, f"Expected Square in results, got: {names}" def test_check_outside_polygon(self, client): """Point clearly outside the geofence — returns empty list.""" self._create_square_geofence(client, "Tiny Box", lat=0, lng=0, size=1) r = client.post("/api/geofences/check", json={"lat": 50, "lng": 50}) assert r.status_code == 200 data = r.json() assert data == [], f"Expected empty list, got: {data}" def test_check_no_geofences(self, client): """No geofences exist — empty array.""" r = client.post("/api/geofences/check", json={"lat": 0, "lng": 0}) assert r.status_code == 200 assert r.json() == [] def test_check_invalid_input(self, client): """Missing lat/lng returns 422.""" r = client.post("/api/geofences/check", json={}) assert r.status_code == 422 # ═══════════════════════════════════════════════════════════════════════════ # 2. Proximity search # ═══════════════════════════════════════════════════════════════════════════ class TestProximitySearch: """/api/proximity — find assets near a GPS point.""" def test_no_assets_nearby(self, client): """No assets exist — empty list.""" r = client.get("/api/proximity?lat=0&lng=0&radius_meters=1000") assert r.status_code == 200 assert r.json() == [] def test_asset_within_radius(self, client): """Asset with lat/lng near the query point.""" aid = client.post("/api/assets", json={ "machine_id": "PROX-001", "name": "Nearby Asset", "latitude": 40.7128, "longitude": -74.006, }).json()["id"] r = client.get("/api/proximity?lat=40.713&lng=-74.007&radius_meters=1000") assert r.status_code == 200 ids = [a["id"] for a in r.json()] assert aid in ids, f"Asset {aid} not in proximity results: {r.json()}" def test_asset_outside_radius(self, client): """Asset far from query point — use NYC vs Tokyo.""" client.post("/api/assets", json={ "machine_id": "PROX-FAR", "name": "Far Asset", "latitude": 40.7128, "longitude": -74.006, }) r = client.get("/api/proximity?lat=35.6762&lng=139.6503&radius_meters=10000") assert r.status_code == 200 machines = [a["machine_id"] for a in r.json()] assert "PROX-FAR" not in machines, f"Far asset unexpectedly in Tokyo proximity: {r.json()}" def test_asset_no_coords(self, client): """Asset without lat/lng should not appear in proximity results.""" client.post("/api/assets", json={ "machine_id": "PROX-NOCOORD", "name": "No Coord Asset", }) r = client.get("/api/proximity?lat=0&lng=0&radius_meters=50000") assert r.status_code == 200 machines = [a["machine_id"] for a in r.json()] assert "PROX-NOCOORD" not in machines, f"Asset without coords unexpectedly in results: {r.json()}" # ═══════════════════════════════════════════════════════════════════════════ # 3. Service Summary Export # ═══════════════════════════════════════════════════════════════════════════ class TestServiceSummaryExport: """/api/export/service-summary — CSV export with visit data.""" def test_export_returns_csv_content_type(self, client): """CSV export sets proper content type.""" r = client.get("/api/export/service-summary") assert r.status_code == 200 assert "text/csv" in r.headers.get("content-type", "").lower() def test_export_has_expected_headers(self, client): """CSV has expected columns.""" r = client.get("/api/export/service-summary") assert r.status_code == 200 text = r.text assert "customer_name" in text or "asset" in text, f"Unexpected headers: {text[:200]}" def test_export_with_data_includes_rows(self, client): """CSV has data rows when assets exist with visits/customers.""" # Create a customer cust = client.post("/api/customers", json={"name": "Test Customer"}).json() # Create a location for the customer loc = client.post("/api/locations", json={ "customer_id": cust["id"], "name": "Test Location", }).json() # Create an asset at that location aid = client.post("/api/assets", json={ "machine_id": "SRV-003", "name": "Service Asset", "customer_id": cust["id"], "location_id": loc["id"], }).json()["id"] # Create a checkin client.post("/api/checkins", json={"asset_id": aid, "notes": "Service visit"}) r = client.get("/api/export/service-summary") assert r.status_code == 200 lines = r.text.strip().split("\n") assert len(lines) >= 2, f"Expected header + data rows, got {len(lines)} lines: {r.text[:200]}" # CSV aggregates by customer/location; check the data row has our test data assert "Test Customer" in r.text assert "Test Location" in r.text # ═══════════════════════════════════════════════════════════════════════════ # 4. Settings Models CRUD # ═══════════════════════════════════════════════════════════════════════════ class TestSettingsModelsCRUD: """Models have make_id dependency — test full lifecycle.""" def test_create_model_with_make(self, client): """Create a make, then a model referencing it.""" make = client.post("/api/settings/makes", json={"name": "TestMake"}).json() make_id = make["id"] r = client.post("/api/settings/models", json={ "make_id": make_id, "name": "TestModel", }) assert r.status_code == 201 data = r.json() assert data["name"] == "TestModel" assert data["make_id"] == make_id def test_create_model_without_make_fails(self, client): """Missing make_id returns 422.""" r = client.post("/api/settings/models", json={"name": "Orphan Model"}) assert r.status_code == 422 def test_list_models(self, client): """List models.""" r = client.get("/api/settings/models") assert r.status_code == 200 assert isinstance(r.json(), list) def test_update_model(self, client): """Update a model's name.""" make = client.post("/api/settings/makes", json={"name": "MakeForUpdate"}).json() model = client.post("/api/settings/models", json={ "make_id": make["id"], "name": "OldName", }).json() r = client.put(f"/api/settings/models/{model['id']}", json={"name": "NewName"}) assert r.status_code == 200 assert r.json()["name"] == "NewName" def test_get_single_model(self, client): """Get a single model by id.""" make = client.post("/api/settings/makes", json={"name": "MakeForGet"}).json() model = client.post("/api/settings/models", json={ "make_id": make["id"], "name": "GetMe", }).json() r = client.get(f"/api/settings/models/{model['id']}") assert r.status_code == 200 assert r.json()["name"] == "GetMe" def test_delete_model(self, client): """Delete a model.""" make = client.post("/api/settings/makes", json={"name": "MakeForDel"}).json() model = client.post("/api/settings/models", json={ "make_id": make["id"], "name": "DeleteMe", }).json() r = client.delete(f"/api/settings/models/{model['id']}") assert r.status_code == 204 # ═══════════════════════════════════════════════════════════════════════════ # 5. Auth-aware smoke test (smoke_test.sh replacement) # ═══════════════════════════════════════════════════════════════════════════ class TestAuthSmokeWorkflow: """Full E2E workflow with auth: login → CRUD → checkin → verify.""" def test_full_workflow_with_auth(self, client): auth = login(client) # Create asset r = client.post("/api/assets", json={ "machine_id": "E2E-001", "name": "E2E Test Asset", "category": "Equipment", }, headers=auth) assert r.status_code == 201 aid = r.json()["id"] # Search by machine_id r = client.get("/api/assets/search?machine_id=E2E-001", headers=auth) assert r.status_code == 200 assert len(r.json()) > 0 # Create checkin r = client.post("/api/checkins", json={ "asset_id": aid, "latitude": 40.7128, "longitude": -74.006, "notes": "Found on site", }, headers=auth) assert r.status_code == 201 # Verify stats r = client.get("/api/stats", headers=auth) assert r.status_code == 200 data = r.json() assert data["total_assets"] >= 1 assert data["total_checkins"] >= 1 # CSV export r = client.get("/api/export/assets", headers=auth) assert r.status_code == 200 assert "E2E-001" in r.text # Delete r = client.delete(f"/api/assets/{aid}", headers=auth) assert r.status_code in (200, 204), f"Expected 200 or 204, got {r.status_code}: {r.text}" # Verify deleted r = client.get(f"/api/assets/{aid}", headers=auth) assert r.status_code == 404