Files

331 lines
14 KiB
Python

"""
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