""" Tests for Canteen Asset Tracker server — v2 schema. Covers: DB tables, asset CRUD with machine_id, checkins, stats, CSV export, v2 schema migration, seed data, and new v2 tables. """ import os import sys import sqlite3 import tempfile import pytest from pathlib import Path from fastapi.testclient import TestClient def _get_test_db(): """Return a worker-isolated test DB path for xdist safety.""" worker_id = os.environ.get("PYTEST_XDIST_WORKER", "master") return Path(__file__).parent / f"test_assets_{worker_id}.db" TEST_DB = _get_test_db() os.environ["CANTEEN_DB_PATH"] = str(TEST_DB) os.environ["CANTEEN_SKIP_AUTH"] = "1" # Skip auth enforcement for all tests by default def pytest_configure(): """Ensure server module can be imported.""" import sys sys.path.insert(0, str(Path(__file__).parent.parent)) @pytest.fixture(autouse=True) def clean_db(): """Remove test DB and WAL/SHM files before each test for isolation.""" db = _get_test_db() os.environ["CANTEEN_DB_PATH"] = str(db) for suffix in ("", "-shm", "-wal", "-journal"): p = db.with_suffix(db.suffix + suffix) if p.exists(): p.unlink() yield for suffix in ("", "-shm", "-wal", "-journal"): p = db.with_suffix(db.suffix + suffix) if p.exists(): p.unlink() class _AuthTestClient: """Wrap TestClient to auto-inject Authorization header on all requests.""" def __init__(self, client, auth_headers): self._client = client self._auth = auth_headers def _merge(self, kwargs): h = dict(kwargs.pop("headers", {})) h.update(self._auth) kwargs["headers"] = h return kwargs def get(self, url, **kwargs): return self._client.get(url, **self._merge(kwargs)) def post(self, url, **kwargs): return self._client.post(url, **self._merge(kwargs)) def put(self, url, **kwargs): return self._client.put(url, **self._merge(kwargs)) def patch(self, url, **kwargs): return self._client.patch(url, **self._merge(kwargs)) def delete(self, url, **kwargs): return self._client.delete(url, **self._merge(kwargs)) def options(self, url, **kwargs): return self._client.options(url, **self._merge(kwargs)) @pytest.fixture def client(clean_db): """Import server and return TestClient using context manager (triggers lifespan).""" import importlib # Always ensure auth is skipped for this test client os.environ["CANTEEN_SKIP_AUTH"] = "1" # Clear cached imports so clean_db takes effect for mod in list(sys.modules.keys()): if mod == "server" or mod.startswith("server."): del sys.modules[mod] import server importlib.invalidate_caches() # Use context manager to trigger lifespan startup with TestClient(server.app) as tc: yield tc # Lifespan shutdown fires on exit @pytest.fixture def auth_client(clean_db): """Return authenticated TestClient (auth enforced, no skip).""" import importlib old = os.environ.pop("CANTEEN_SKIP_AUTH", None) for mod in list(sys.modules.keys()): if mod == "server" or mod.startswith("server."): del sys.modules[mod] import server importlib.invalidate_caches() with TestClient(server.app) as tc: # Login to get admin token for auth headers tc.get("/health") # Ensure lifespan triggers login_resp = tc.post("/api/auth/login", json={"username": "admin", "password": "changeme"}) if login_resp.status_code == 200: token = login_resp.json()["token"] auth_headers = {"Authorization": f"Bearer {token}"} else: auth_headers = {} wrapper = _AuthTestClient(tc, auth_headers) yield wrapper if old is not None: os.environ["CANTEEN_SKIP_AUTH"] = old @pytest.fixture def unauth_client(clean_db): """Return unauthenticated TestClient (auth enforced, no auto-injected auth).""" import importlib old = os.environ.pop("CANTEEN_SKIP_AUTH", None) for mod in list(sys.modules.keys()): if mod == "server" or mod.startswith("server."): del sys.modules[mod] import server importlib.invalidate_caches() with TestClient(server.app) as tc: yield tc if old is not None: os.environ["CANTEEN_SKIP_AUTH"] = old # ─── Task 3: Skeleton & DB ──────────────────────────────────────────────── class TestHealthEndpoint: """Task 3 — health check.""" def test_health_returns_200(self, client): response = client.get("/health") assert response.status_code == 200 def test_health_returns_json_with_status(self, client): response = client.get("/health") data = response.json() assert data["status"] == "ok" class TestDBSetup: """Task 3 — database tables created on startup (v2 schema).""" def test_assets_table_exists(self, client): conn = sqlite3.connect(str(TEST_DB)) cursor = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='assets'" ) assert cursor.fetchone() is not None conn.close() def test_checkins_table_exists(self, client): conn = sqlite3.connect(str(TEST_DB)) cursor = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' AND name='checkins'" ) assert cursor.fetchone() is not None conn.close() def test_assets_schema_has_required_columns(self, client): """Verify v2 assets columns are present.""" conn = sqlite3.connect(str(TEST_DB)) cursor = conn.execute("PRAGMA table_info(assets)") cols = {row[1] for row in cursor.fetchall()} required = { "id", "machine_id", "serial_number", "name", "description", "category", "status", "make", "model", "address", "building_name", "building_number", "floor", "room", "trailer_number", "walking_directions", "map_link", "parking_location", "photo_path", "customer_id", "location_id", "assigned_to", "created_at", "updated_at", } assert required.issubset(cols), f"Missing: {required - cols}" conn.close() def test_checkins_schema_has_required_columns(self, client): """Verify v2 checkins columns including user_id.""" conn = sqlite3.connect(str(TEST_DB)) cursor = conn.execute("PRAGMA table_info(checkins)") cols = {row[1] for row in cursor.fetchall()} required = { "id", "asset_id", "user_id", "latitude", "longitude", "accuracy", "photo_path", "notes", "created_at", } assert required.issubset(cols), f"Missing: {required - cols}" conn.close() class TestCORSAndStatic: """Task 3 — CORS middleware and static file mount.""" def test_cors_headers_present(self, client): response = client.options( "/health", headers={"Origin": "https://example.com", "Access-Control-Request-Method": "GET"}, ) assert response.status_code in (200, 405) def test_static_index_served(self, client): """Ensure static/index.html is reachable at root.""" response = client.get("/") assert response.status_code in (200, 404) # ─── Task 4: POST /api/assets ───────────────────────────────────────────── class TestCreateAsset: """Task 4 — create asset endpoint (v2: machine_id replaces barcode).""" def test_create_asset_returns_201_with_id(self, client): payload = { "machine_id": "MACH001", "name": "Test Asset", "description": "A test", "category": "Equipment", } response = client.post("/api/assets", json=payload) assert response.status_code == 201 data = response.json() assert "id" in data assert data["machine_id"] == "MACH001" assert data["name"] == "Test Asset" def test_create_asset_requires_machine_id(self, client): payload = {"name": "No Machine ID"} response = client.post("/api/assets", json=payload) assert response.status_code == 422 def test_create_asset_requires_name(self, client): payload = {"machine_id": "MACH002"} response = client.post("/api/assets", json=payload) assert response.status_code == 422 def test_create_asset_duplicate_machine_id_rejected(self, client): """machine_id is UNIQUE — duplicate insertion returns 409.""" payload = {"machine_id": "MACH003", "name": "First"} r1 = client.post("/api/assets", json=payload) assert r1.status_code == 201 r2 = client.post("/api/assets", json=payload) assert r2.status_code == 409 assert "already exists" in r2.json()["detail"] def test_create_asset_invalid_category_rejected(self, client): payload = {"machine_id": "MACH004", "name": "Bad Cat", "category": "InvalidCategory"} response = client.post("/api/assets", json=payload) assert response.status_code == 422 def test_create_asset_defaults_category_to_other(self, client): payload = {"machine_id": "MACH005", "name": "No Category"} response = client.post("/api/assets", json=payload) assert response.status_code == 201 assert response.json()["category"] == "Other" def test_create_asset_defaults_status_to_active(self, client): payload = {"machine_id": "MACH006", "name": "Default Status"} response = client.post("/api/assets", json=payload) assert response.json()["status"] == "active" def test_create_asset_with_all_new_fields(self, client): """Create asset with v2-specific fields (address, make, model, etc.).""" payload = { "machine_id": "MACH007", "name": "Full Asset", "serial_number": "SN-12345", "make": "Hobart", "model": "HLX-200", "address": "123 Main St", "building_name": "HQ", "building_number": "B1", "floor": "2", "room": "201A", "trailer_number": "T-5", "walking_directions": "Through lobby, turn left", "map_link": "https://maps.example.com/123", "parking_location": "Lot A, Spot 42", } response = client.post("/api/assets", json=payload) assert response.status_code == 201 data = response.json() assert data["serial_number"] == "SN-12345" assert data["make"] == "Hobart" assert data["model"] == "HLX-200" assert data["building_name"] == "HQ" assert data["floor"] == "2" assert data["room"] == "201A" def test_create_asset_new_fields_default_empty(self, client): payload = {"machine_id": "MACH008", "name": "Minimal"} response = client.post("/api/assets", json=payload) assert response.status_code == 201 data = response.json() assert data["serial_number"] == "" assert data["make"] == "" assert data["model"] == "" assert data["address"] == "" assert data["building_name"] == "" # ─── Task 5: GET /api/assets and GET /api/assets/{id} ───────────────────── class TestListAssets: """Task 5 — list and detail endpoints (v2).""" def test_list_assets_returns_array(self, client): response = client.get("/api/assets") assert response.status_code == 200 assert isinstance(response.json(), list) def test_list_assets_returns_created_asset(self, client): payload = {"machine_id": "LIST001", "name": "Listable"} client.post("/api/assets", json=payload) response = client.get("/api/assets") assets = response.json() assert len(assets) == 1 assert assets[0]["machine_id"] == "LIST001" def test_list_assets_filter_by_category(self, client): client.post("/api/assets", json={"machine_id": "C1", "name": "Cat1", "category": "Furniture"}) client.post("/api/assets", json={"machine_id": "C2", "name": "Cat2", "category": "Appliances"}) response = client.get("/api/assets?category=Furniture") assets = response.json() assert len(assets) == 1 assert assets[0]["category"] == "Furniture" def test_list_assets_filter_by_status(self, client): client.post("/api/assets", json={"machine_id": "S1", "name": "Active", "status": "active"}) client.post("/api/assets", json={"machine_id": "S2", "name": "Retired", "status": "retired"}) response = client.get("/api/assets?status=retired") assets = response.json() assert len(assets) == 1 assert assets[0]["status"] == "retired" def test_list_assets_text_search(self, client): client.post("/api/assets", json={"machine_id": "Q1", "name": "Coffee Machine"}) client.post("/api/assets", json={"machine_id": "Q2", "name": "Toaster Oven"}) response = client.get("/api/assets?q=coffee") assets = response.json() assert len(assets) == 1 assert assets[0]["name"] == "Coffee Machine" def test_list_assets_text_search_by_machine_id(self, client): """Search by machine_id via text query.""" client.post("/api/assets", json={"machine_id": "MX-9000", "name": "Mixer"}) client.post("/api/assets", json={"machine_id": "BL-1000", "name": "Blender"}) response = client.get("/api/assets?q=MX-9000") assets = response.json() assert len(assets) == 1 assert assets[0]["machine_id"] == "MX-9000" def test_get_single_asset_returns_200(self, client): r = client.post("/api/assets", json={"machine_id": "GET001", "name": "Single"}) asset_id = r.json()["id"] response = client.get(f"/api/assets/{asset_id}") assert response.status_code == 200 assert response.json()["name"] == "Single" def test_get_single_asset_not_found_returns_404(self, client): response = client.get("/api/assets/99999") assert response.status_code == 404 # ─── Task 6: PUT / DELETE /api/assets/{id} ──────────────────────────────── class TestUpdateAsset: """Task 6 — update endpoint (v2).""" def test_update_asset_name(self, client): r = client.post("/api/assets", json={"machine_id": "UP001", "name": "Old Name"}) asset_id = r.json()["id"] response = client.put(f"/api/assets/{asset_id}", json={"name": "New Name"}) assert response.status_code == 200 assert response.json()["name"] == "New Name" def test_update_asset_machine_id(self, client): r = client.post("/api/assets", json={"machine_id": "UP002", "name": "Machine"}) asset_id = r.json()["id"] response = client.put(f"/api/assets/{asset_id}", json={"machine_id": "UP002-NEW"}) assert response.status_code == 200 assert response.json()["machine_id"] == "UP002-NEW" def test_update_asset_new_fields(self, client): """Update v2-specific fields.""" r = client.post("/api/assets", json={"machine_id": "UP003", "name": "Asset"}) asset_id = r.json()["id"] response = client.put(f"/api/assets/{asset_id}", json={ "make": "Vollrath", "model": "VX-500", "building_name": "Warehouse B", "floor": "3", "walking_directions": "Take elevator to 3rd floor", }) assert response.status_code == 200 data = response.json() assert data["make"] == "Vollrath" assert data["model"] == "VX-500" assert data["building_name"] == "Warehouse B" assert data["floor"] == "3" assert data["walking_directions"] == "Take elevator to 3rd floor" def test_update_asset_not_found_returns_404(self, client): response = client.put("/api/assets/99999", json={"name": "Nope"}) assert response.status_code == 404 class TestDeleteAsset: """Task 6 — delete endpoint.""" def test_delete_asset_returns_204(self, client): r = client.post("/api/assets", json={"machine_id": "DEL001", "name": "Deletable"}) asset_id = r.json()["id"] response = client.delete(f"/api/assets/{asset_id}") assert response.status_code == 204 def test_delete_asset_not_found_returns_404(self, client): response = client.delete("/api/assets/99999") assert response.status_code == 404 # ─── Task 7: GET /api/assets/search?machine_id= ─────────────────────────── class TestSearchByMachineId: """Task 7 — machine_id search endpoint (was barcode in v1).""" def test_search_by_machine_id_returns_asset(self, client): client.post("/api/assets", json={"machine_id": "SRCH001", "name": "Searchable"}) response = client.get("/api/assets/search?machine_id=SRCH001") assert response.status_code == 200 assert response.json()["name"] == "Searchable" assert response.json()["machine_id"] == "SRCH001" def test_search_by_machine_id_not_found_returns_404(self, client): response = client.get("/api/assets/search?machine_id=NONEXISTENT") assert response.status_code == 404 def test_search_by_machine_id_multiple_returns_first(self, client): """Multiple assets with same machine_id → returns first match.""" client.post("/api/assets", json={"machine_id": "DUP001", "name": "First"}) client.post("/api/assets", json={"machine_id": "DUP001", "name": "Second"}) response = client.get("/api/assets/search?machine_id=DUP001") assert response.status_code == 200 assert response.json()["machine_id"] == "DUP001" # ─── Task 8: POST /api/checkins ──────────────────────────────────────────── class TestCreateCheckin: """Task 8 — create a check-in for an asset.""" @pytest.fixture def asset_id(self, client): r = client.post("/api/assets", json={"machine_id": "CHK001", "name": "Checkin Asset"}) return r.json()["id"] def test_create_checkin_returns_201_with_all_fields(self, client, asset_id): payload = { "asset_id": asset_id, "latitude": 40.7128, "longitude": -74.0060, "accuracy": 10.5, "notes": "Found in storage room", } response = client.post("/api/checkins", json=payload) assert response.status_code == 201 data = response.json() assert data["asset_id"] == asset_id assert data["latitude"] == 40.7128 assert data["longitude"] == -74.0060 assert data["accuracy"] == 10.5 assert data["notes"] == "Found in storage room" assert "id" in data assert "created_at" in data def test_create_checkin_requires_asset_id(self, client): payload = {"latitude": 40.7128, "longitude": -74.0060} response = client.post("/api/checkins", json=payload) assert response.status_code == 422 def test_create_checkin_asset_not_found_returns_404(self, client): payload = {"asset_id": 99999, "latitude": 40.7128, "longitude": -74.0060} response = client.post("/api/checkins", json=payload) assert response.status_code == 404 def test_create_checkin_without_location_allowed(self, client, asset_id): payload = {"asset_id": asset_id, "notes": "Just a note, no GPS"} response = client.post("/api/checkins", json=payload) assert response.status_code == 201 data = response.json() assert data["latitude"] is None assert data["longitude"] is None assert data["notes"] == "Just a note, no GPS" def test_create_checkin_notes_default_empty(self, client, asset_id): payload = {"asset_id": asset_id, "latitude": 40.7128, "longitude": -74.0060} response = client.post("/api/checkins", json=payload) assert response.json()["notes"] == "" # ─── Task 9: GET /api/checkins ───────────────────────────────────────────── class TestListCheckins: """Task 9 — list and filter check-ins.""" def test_list_checkins_returns_array(self, client): response = client.get("/api/checkins") assert response.status_code == 200 assert isinstance(response.json(), list) def test_list_checkins_filter_by_asset_id(self, client): r1 = client.post("/api/assets", json={"machine_id": "CHKL1", "name": "Asset 1"}) r2 = client.post("/api/assets", json={"machine_id": "CHKL2", "name": "Asset 2"}) a1 = r1.json()["id"] client.post("/api/checkins", json={"asset_id": a1, "latitude": 40.0, "longitude": -74.0}) response = client.get(f"/api/checkins?asset_id={a1}") checkins = response.json() assert len(checkins) == 1 assert checkins[0]["asset_id"] == a1 def test_list_checkins_empty_for_unchecked_asset(self, client): r = client.post("/api/assets", json={"machine_id": "CHKL3", "name": "No Checkins"}) a_id = r.json()["id"] response = client.get(f"/api/checkins?asset_id={a_id}") assert response.json() == [] def test_list_checkins_ordered_by_newest_first(self, client): r = client.post("/api/assets", json={"machine_id": "CHKL4", "name": "Order Test"}) a_id = r.json()["id"] client.post("/api/checkins", json={"asset_id": a_id, "notes": "First"}) client.post("/api/checkins", json={"asset_id": a_id, "notes": "Second"}) checkins = client.get(f"/api/checkins?asset_id={a_id}").json() assert checkins[0]["notes"] == "Second" assert checkins[1]["notes"] == "First" def test_list_checkins_pagination(self, client): r = client.post("/api/assets", json={"machine_id": "CHKL5", "name": "Pagination"}) a_id = r.json()["id"] for i in range(5): client.post("/api/checkins", json={"asset_id": a_id, "notes": f"Note {i}"}) response = client.get(f"/api/checkins?asset_id={a_id}&limit=2&offset=1") checkins = response.json() assert len(checkins) == 2 # ─── Task 10: GET /api/stats ──────────────────────────────────────────────── class TestStats: """Task 10 — aggregated statistics.""" def test_stats_returns_total_assets_and_checkins(self, client): response = client.get("/api/stats") assert response.status_code == 200 data = response.json() assert "total_assets" in data assert "total_checkins" in data def test_stats_counts_reflect_data(self, client): client.post("/api/assets", json={"machine_id": "ST001", "name": "S1", "category": "Furniture"}) r = client.post("/api/assets", json={"machine_id": "ST002", "name": "S2", "category": "Equipment"}) client.post("/api/assets", json={"machine_id": "ST003", "name": "S3", "category": "Furniture", "status": "retired"}) a_id = r.json()["id"] client.post("/api/checkins", json={"asset_id": a_id, "latitude": 40.0, "longitude": -74.0}) client.post("/api/checkins", json={"asset_id": a_id, "latitude": 41.0, "longitude": -75.0}) data = client.get("/api/stats").json() assert data["total_assets"] == 3 assert data["total_checkins"] == 2 def test_stats_category_breakdown(self, client): client.post("/api/assets", json={"machine_id": "STC1", "name": "C1", "category": "Furniture"}) client.post("/api/assets", json={"machine_id": "STC2", "name": "C2", "category": "Furniture"}) client.post("/api/assets", json={"machine_id": "STC3", "name": "C3", "category": "Equipment"}) data = client.get("/api/stats").json() cats = data.get("by_category", data.get("categories", {})) if cats: assert cats.get("Furniture") == 2 or cats.get("Furniture", 0) >= 2 assert cats.get("Equipment") == 1 or cats.get("Equipment", 0) >= 1 def test_stats_status_breakdown(self, client): client.post("/api/assets", json={"machine_id": "STS1", "name": "S1", "status": "active"}) client.post("/api/assets", json={"machine_id": "STS2", "name": "S2", "status": "maintenance"}) data = client.get("/api/stats").json() statuses = data.get("by_status", data.get("statuses", {})) if statuses: assert statuses.get("active") == 1 or statuses.get("active", 0) >= 1 assert statuses.get("maintenance") == 1 or statuses.get("maintenance", 0) >= 1 def test_stats_empty_state(self, client): data = client.get("/api/stats").json() assert data["total_assets"] == 0 assert data["total_checkins"] == 0 # ─── Task 11: CSV Export ──────────────────────────────────────────────────── class TestCSVExport: """Task 11 — CSV export endpoints (v2 columns).""" def test_export_assets_csv_returns_csv_content_type(self, client): client.post("/api/assets", json={"machine_id": "EXP001", "name": "Exportable"}) response = client.get("/api/export/assets") assert response.status_code == 200 assert "text/csv" in response.headers["content-type"] def test_export_assets_csv_contains_header(self, client): client.post("/api/assets", json={"machine_id": "EXP002", "name": "Header Test"}) response = client.get("/api/export/assets") assert response.status_code == 200 body = response.text # V2 header includes machine_id assert "machine_id" in body def test_export_assets_csv_contains_data(self, client): client.post("/api/assets", json={"machine_id": "EXP003", "name": "Data Test"}) response = client.get("/api/export/assets") assert response.status_code == 200 body = response.text assert "EXP003" in body def test_export_checkins_csv_returns_csv_content_type(self, client): r = client.post("/api/assets", json={"machine_id": "EXPC1", "name": "Checkin CSV"}) a_id = r.json()["id"] client.post("/api/checkins", json={"asset_id": a_id, "latitude": 40.0, "longitude": -74.0}) response = client.get("/api/export/checkins") assert response.status_code == 200 assert "text/csv" in response.headers["content-type"] def test_export_checkins_csv_filter_by_asset_id(self, client): r1 = client.post("/api/assets", json={"machine_id": "EXPC2", "name": "CSV Asset 1"}) r2 = client.post("/api/assets", json={"machine_id": "EXPC3", "name": "CSV Asset 2"}) a1 = r1.json()["id"] a2 = r2.json()["id"] client.post("/api/checkins", json={"asset_id": a1, "notes": "A"}) client.post("/api/checkins", json={"asset_id": a2, "notes": "B"}) response = client.get(f"/api/export/checkins?asset_id={a1}") body = response.text assert "A" in body # ─── v2 Schema: New Tables ─────────────────────────────────────────────── class TestV2Tables: """Verify all 17 new v2 tables exist after init_db().""" EXPECTED_TABLES = { "users", "customers", "customer_contacts", "locations", "rooms", "categories", "key_names", "key_types", "badge_types", "makes", "models", "asset_keys", "asset_badges", "settings", "activity_log", "geofences", "visits", } def test_all_v2_tables_exist(self, client): conn = sqlite3.connect(str(TEST_DB)) cursor = conn.execute( "SELECT name FROM sqlite_master WHERE type='table' ORDER BY name" ) tables = {row[0] for row in cursor.fetchall()} missing = self.EXPECTED_TABLES - tables assert not missing, f"Missing tables: {missing}" conn.close() @pytest.mark.parametrize("table,expected_cols", [ ("users", {"id", "username", "password_hash", "role", "created_at"}), ("customers", {"id", "name", "created_at", "updated_at"}), ("customer_contacts", {"id", "customer_id", "name", "phone", "email"}), ("locations", {"id", "customer_id", "name", "address", "building_name", "building_number", "floor", "trailer_number", "site_hours", "access_notes", "walking_directions", "map_link", "created_at", "updated_at"}), ("rooms", {"id", "location_id", "name", "floor", "created_at", "updated_at"}), ("asset_keys", {"id", "asset_id", "key_name", "key_type"}), ("asset_badges", {"id", "asset_id", "badge_name"}), ("settings", {"id", "key", "value"}), ("activity_log", {"id", "user_id", "action", "entity_type", "entity_id", "details", "created_at"}), ("geofences", {"id", "name", "points", "color", "created_at", "updated_at"}), ("visits", {"id", "user_id", "asset_id", "checkin_time", "checkout_time", "duration_minutes", "created_at"}), ]) def test_table_schema(self, client, table, expected_cols): conn = sqlite3.connect(str(TEST_DB)) cursor = conn.execute(f"PRAGMA table_info({table})") cols = {row[1] for row in cursor.fetchall()} assert expected_cols.issubset(cols), f"{table}: missing {expected_cols - cols}" conn.close() # ─── v2 Schema: Seed Data ──────────────────────────────────────────────── class TestSeedData: """Verify default seed data is populated on fresh install.""" def test_categories_seeded(self, client): conn = sqlite3.connect(str(TEST_DB)) conn.row_factory = sqlite3.Row rows = conn.execute("SELECT name FROM categories ORDER BY name").fetchall() names = {r["name"] for r in rows} assert "Furniture" in names assert "Appliances" in names assert "Equipment" in names conn.close() def test_key_names_seeded(self, client): conn = sqlite3.connect(str(TEST_DB)) conn.row_factory = sqlite3.Row rows = conn.execute("SELECT name FROM key_names").fetchall() names = {r["name"] for r in rows} assert "MK500" in names assert "Green Dot" in names conn.close() def test_key_types_seeded(self, client): conn = sqlite3.connect(str(TEST_DB)) conn.row_factory = sqlite3.Row rows = conn.execute("SELECT name FROM key_types").fetchall() names = {r["name"] for r in rows} assert "Round Short" in names assert "Barrel" in names conn.close() def test_badge_types_seeded(self, client): conn = sqlite3.connect(str(TEST_DB)) conn.row_factory = sqlite3.Row rows = conn.execute("SELECT name FROM badge_types").fetchall() names = {r["name"] for r in rows} assert "Disney Contractor Base" in names conn.close() def test_makes_seeded(self, client): conn = sqlite3.connect(str(TEST_DB)) conn.row_factory = sqlite3.Row rows = conn.execute("SELECT name FROM makes").fetchall() names = {r["name"] for r in rows} assert "Canteen" in names assert "Hobart" in names conn.close() def test_default_admin_user_exists(self, client): conn = sqlite3.connect(str(TEST_DB)) conn.row_factory = sqlite3.Row row = conn.execute( "SELECT username, role FROM users WHERE username = 'admin'" ).fetchone() assert row is not None assert row["username"] == "admin" assert row["role"] == "admin" conn.close() def test_seed_data_idempotent(self, client): """Re-initializing should not duplicate seed data.""" conn = sqlite3.connect(str(TEST_DB)) # Import server and run init_db again import server import importlib importlib.reload(server) server.init_db(conn) # Count should still be the same cat_count = conn.execute("SELECT COUNT(*) FROM categories").fetchone()[0] assert cat_count == 5 # original 5, not 10 conn.close() # ─── v2 Schema: Migration from v1 ───────────────────────────────────────── class TestMigration: """Verify v1→v2 migration preserves existing data.""" def test_migration_from_v1_to_v2(self): """Create a v1 DB, run migration, verify data preserved and schema upgraded.""" # Build a v1 database by hand (simulating old state) db_path = Path(__file__).parent / "test_migrate.db" if db_path.exists(): db_path.unlink() conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA foreign_keys=ON") # Create v1 schema conn.executescript(""" CREATE TABLE assets ( id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT DEFAULT '', category TEXT NOT NULL DEFAULT 'Other', status TEXT NOT NULL DEFAULT 'active', photo_path TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE checkins ( id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE, latitude REAL, longitude REAL, accuracy REAL, photo_path TEXT, notes TEXT DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); """) # Insert test data conn.execute( "INSERT INTO assets (barcode, name, description, category, status) VALUES (?, ?, ?, ?, ?)", ("OLD001", "Old Asset", "Legacy data", "Equipment", "active"), ) conn.execute( "INSERT INTO assets (barcode, name, category) VALUES (?, ?, ?)", ("OLD002", "Second Asset", "Furniture"), ) conn.commit() conn.close() # Now run the server's init_db on it import server import importlib importlib.reload(server) # Temporarily point DB_PATH at our test DB old_db_path = server.DB_PATH # We need to monkey-patch the get_db path. Instead, use init_db directly. conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row server.init_db(conn) conn.close() # Verify migration results conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row # Check old data preserved with machine_id mapping rows = conn.execute("SELECT * FROM assets ORDER BY id").fetchall() assert len(rows) == 2 assert rows[0]["machine_id"] == "OLD001" assert rows[0]["name"] == "Old Asset" assert rows[0]["category"] == "Equipment" assert rows[1]["machine_id"] == "OLD002" assert rows[1]["name"] == "Second Asset" # Check new columns exist with defaults assert rows[0]["serial_number"] == "" assert rows[0]["make"] == "" assert rows[0]["building_name"] == "" # Check barcode column is gone cursor = conn.execute("PRAGMA table_info(assets)") cols = {row[1] for row in cursor.fetchall()} assert "barcode" not in cols assert "machine_id" in cols # Check checkins got user_id column cursor = conn.execute("PRAGMA table_info(checkins)") checkin_cols = {row[1] for row in cursor.fetchall()} assert "user_id" in checkin_cols # Check new tables exist tables = conn.execute( "SELECT name FROM sqlite_master WHERE type='table'" ).fetchall() table_names = {r["name"] for r in tables} assert "customers" in table_names assert "users" in table_names assert "categories" in table_names conn.close() # Cleanup if db_path.exists(): db_path.unlink() def test_migration_idempotent(self): """Running init_db twice on a v1 DB should not error.""" db_path = Path(__file__).parent / "test_migrate2.db" if db_path.exists(): db_path.unlink() conn = sqlite3.connect(str(db_path)) conn.executescript(""" CREATE TABLE assets ( id INTEGER PRIMARY KEY AUTOINCREMENT, barcode TEXT UNIQUE NOT NULL, name TEXT NOT NULL, description TEXT DEFAULT '', category TEXT NOT NULL DEFAULT 'Other', status TEXT NOT NULL DEFAULT 'active', photo_path TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE checkins ( id INTEGER PRIMARY KEY AUTOINCREMENT, asset_id INTEGER NOT NULL REFERENCES assets(id) ON DELETE CASCADE, latitude REAL, longitude REAL, accuracy REAL, photo_path TEXT, notes TEXT DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')) ); INSERT INTO assets (barcode, name) VALUES ('IDEM001', 'Idempotent'); """) conn.commit() conn.close() import server import importlib importlib.reload(server) # First migration conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row server.init_db(conn) conn.close() # Second init (should be a no-op) conn = sqlite3.connect(str(db_path)) conn.execute("PRAGMA foreign_keys=ON") conn.row_factory = sqlite3.Row server.init_db(conn) conn.close() # Verify data still intact conn = sqlite3.connect(str(db_path)) conn.row_factory = sqlite3.Row rows = conn.execute("SELECT * FROM assets").fetchall() assert len(rows) == 1 assert rows[0]["machine_id"] == "IDEM001" conn.close() if db_path.exists(): db_path.unlink() # ─── Phase B: Customers API ────────────────────────────────────────────── class TestCreateCustomer: """POST /api/customers — create with contacts array.""" def test_create_customer_returns_201_with_id(self, client): payload = {"name": "Acme Corp"} r = client.post("/api/customers", json=payload) assert r.status_code == 201 data = r.json() assert "id" in data assert data["name"] == "Acme Corp" def test_create_customer_with_contacts(self, client): payload = { "name": "Acme Corp", "contacts": [ {"name": "John Doe", "phone": "555-0100", "email": "john@acme.com"}, {"name": "Jane Smith", "phone": "555-0200", "email": "jane@acme.com"}, ], } r = client.post("/api/customers", json=payload) assert r.status_code == 201 data = r.json() assert len(data["contacts"]) == 2 assert data["contacts"][0]["name"] == "John Doe" def test_create_customer_requires_name(self, client): r = client.post("/api/customers", json={}) assert r.status_code == 422 class TestListCustomers: """GET /api/customers — list all.""" def test_list_customers_returns_array(self, client): r = client.get("/api/customers") assert r.status_code == 200 assert isinstance(r.json(), list) def test_list_customers_returns_created(self, client): client.post("/api/customers", json={"name": "TestCo"}) r = client.get("/api/customers") data = r.json() assert len(data) == 1 assert data[0]["name"] == "TestCo" class TestGetCustomer: """GET /api/customers/{id} — detail with contacts.""" def test_get_customer_returns_200(self, client): r = client.post("/api/customers", json={ "name": "DetailCo", "contacts": [{"name": "Contact 1", "phone": "555-1111"}], }) cust_id = r.json()["id"] r = client.get(f"/api/customers/{cust_id}") assert r.status_code == 200 data = r.json() assert data["name"] == "DetailCo" assert len(data["contacts"]) == 1 def test_get_customer_not_found_returns_404(self, client): r = client.get("/api/customers/99999") assert r.status_code == 404 class TestUpdateCustomer: """PUT /api/customers/{id} — update.""" def test_update_customer_name(self, client): r = client.post("/api/customers", json={"name": "OldName"}) cust_id = r.json()["id"] r = client.put(f"/api/customers/{cust_id}", json={"name": "NewName"}) assert r.status_code == 200 assert r.json()["name"] == "NewName" def test_update_customer_contacts_replaces_all(self, client): r = client.post("/api/customers", json={ "name": "ReplaceCo", "contacts": [{"name": "Old", "phone": "555-0000"}], }) cust_id = r.json()["id"] r = client.put(f"/api/customers/{cust_id}", json={ "contacts": [{"name": "New", "phone": "555-9999"}], }) assert r.status_code == 200 data = r.json() assert len(data["contacts"]) == 1 assert data["contacts"][0]["name"] == "New" def test_update_customer_not_found(self, client): r = client.put("/api/customers/99999", json={"name": "Nope"}) assert r.status_code == 404 class TestDeleteCustomer: """DELETE /api/customers/{id} — cascade.""" def test_delete_customer_returns_204(self, client): r = client.post("/api/customers", json={"name": "DeleteMe"}) cust_id = r.json()["id"] r = client.delete(f"/api/customers/{cust_id}") assert r.status_code == 204 def test_delete_customer_cascades_contacts(self, client): r = client.post("/api/customers", json={ "name": "CascadeCo", "contacts": [{"name": "C1", "phone": "555-0001"}], }) cust_id = r.json()["id"] client.delete(f"/api/customers/{cust_id}") import sqlite3 conn = sqlite3.connect(str(TEST_DB)) rows = conn.execute( "SELECT COUNT(*) FROM customer_contacts WHERE customer_id = ?", (cust_id,) ).fetchone() conn.close() assert rows[0] == 0 def test_delete_customer_not_found(self, client): r = client.delete("/api/customers/99999") assert r.status_code == 404 # ─── Phase B: Locations API ─────────────────────────────────────────────── class TestCreateLocation: """POST /api/locations — create with all fields.""" @pytest.fixture def customer_id(self, client): r = client.post("/api/customers", json={"name": "LocCustomer"}) return r.json()["id"] def test_create_location_returns_201(self, client, customer_id): payload = {"customer_id": customer_id, "name": "Main Office"} r = client.post("/api/locations", json=payload) assert r.status_code == 201 data = r.json() assert data["name"] == "Main Office" def test_create_location_with_all_fields(self, client, customer_id): payload = { "customer_id": customer_id, "name": "HQ", "address": "123 Main St", "building_name": "Tower A", "building_number": "B1", "floor": "5", "trailer_number": "T-1", "site_hours": "9-5", "access_notes": "Ring buzzer", "walking_directions": "Through lobby", "map_link": "https://maps.example.com/1", } r = client.post("/api/locations", json=payload) assert r.status_code == 201 data = r.json() assert data["address"] == "123 Main St" assert data["building_name"] == "Tower A" assert data["site_hours"] == "9-5" def test_create_location_defaults_empty(self, client, customer_id): payload = {"name": "Sparse"} r = client.post("/api/locations", json=payload) assert r.status_code == 201 data = r.json() assert data["address"] == "" class TestListLocations: """GET /api/locations — list, filter by customer_id.""" def test_list_locations_returns_array(self, client): r = client.get("/api/locations") assert r.status_code == 200 assert isinstance(r.json(), list) def test_list_locations_filter_by_customer(self, client): r1 = client.post("/api/customers", json={"name": "CustA"}) r2 = client.post("/api/customers", json={"name": "CustB"}) c1, c2 = r1.json()["id"], r2.json()["id"] client.post("/api/locations", json={"name": "LocA", "customer_id": c1}) client.post("/api/locations", json={"name": "LocB", "customer_id": c2}) r = client.get(f"/api/locations?customer_id={c1}") data = r.json() assert len(data) == 1 assert data[0]["name"] == "LocA" class TestGetLocation: """GET /api/locations/{id} — detail with rooms.""" def test_get_location_with_rooms(self, client): r = client.post("/api/customers", json={"name": "RoomCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "RoomLoc", "customer_id": c_id}) loc_id = r.json()["id"] client.post("/api/rooms", json={"location_id": loc_id, "name": "Room 101"}) client.post("/api/rooms", json={"location_id": loc_id, "name": "Room 102"}) r = client.get(f"/api/locations/{loc_id}") assert r.status_code == 200 data = r.json() assert data["name"] == "RoomLoc" assert len(data["rooms"]) == 2 assert data["rooms"][0]["name"] == "Room 101" def test_get_location_not_found(self, client): r = client.get("/api/locations/99999") assert r.status_code == 404 class TestUpdateLocation: """PUT /api/locations/{id} — update.""" def test_update_location_fields(self, client): r = client.post("/api/customers", json={"name": "UpdCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "OldLoc", "customer_id": c_id}) loc_id = r.json()["id"] r = client.put(f"/api/locations/{loc_id}", json={ "name": "NewLoc", "building_name": "Wing B", }) assert r.status_code == 200 data = r.json() assert data["name"] == "NewLoc" assert data["building_name"] == "Wing B" def test_update_location_not_found(self, client): r = client.put("/api/locations/99999", json={"name": "Nope"}) assert r.status_code == 404 class TestDeleteLocation: """DELETE /api/locations/{id} — cascade rooms.""" def test_delete_location_returns_204(self, client): r = client.post("/api/customers", json={"name": "DelCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "DelLoc", "customer_id": c_id}) loc_id = r.json()["id"] r = client.delete(f"/api/locations/{loc_id}") assert r.status_code == 204 def test_delete_location_cascades_rooms(self, client): r = client.post("/api/customers", json={"name": "CascadeCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "CascadeLoc", "customer_id": c_id}) loc_id = r.json()["id"] client.post("/api/rooms", json={"location_id": loc_id, "name": "R1"}) client.delete(f"/api/locations/{loc_id}") import sqlite3 conn = sqlite3.connect(str(TEST_DB)) rows = conn.execute( "SELECT COUNT(*) FROM rooms WHERE location_id = ?", (loc_id,) ).fetchone() conn.close() assert rows[0] == 0 def test_delete_location_not_found(self, client): r = client.delete("/api/locations/99999") assert r.status_code == 404 # ─── Phase B: Rooms API ─────────────────────────────────────────────────── class TestCreateRoom: """POST /api/rooms — create under location.""" @pytest.fixture def location_id(self, client): r = client.post("/api/customers", json={"name": "RoomCust2"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "RoomLoc2", "customer_id": c_id}) return r.json()["id"] def test_create_room_returns_201(self, client, location_id): payload = {"location_id": location_id, "name": "Server Room"} r = client.post("/api/rooms", json=payload) assert r.status_code == 201 data = r.json() assert data["name"] == "Server Room" assert data["location_id"] == location_id def test_create_room_with_floor(self, client, location_id): payload = {"location_id": location_id, "name": "Basement", "floor": "B1"} r = client.post("/api/rooms", json=payload) assert r.status_code == 201 assert r.json()["floor"] == "B1" def test_create_room_default_floor_empty(self, client, location_id): payload = {"location_id": location_id, "name": "No Floor"} r = client.post("/api/rooms", json=payload) assert r.json()["floor"] == "" class TestUpdateRoom: """PUT /api/rooms/{id} — update.""" def test_update_room_name(self, client): r = client.post("/api/customers", json={"name": "RUCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "RULoc", "customer_id": c_id}) loc_id = r.json()["id"] r = client.post("/api/rooms", json={"location_id": loc_id, "name": "Old Room"}) room_id = r.json()["id"] r = client.put(f"/api/rooms/{room_id}", json={"name": "New Room"}) assert r.status_code == 200 assert r.json()["name"] == "New Room" def test_update_room_not_found(self, client): r = client.put("/api/rooms/99999", json={"name": "Nope"}) assert r.status_code == 404 class TestDeleteRoom: """DELETE /api/rooms/{id}.""" def test_delete_room_returns_204(self, client): r = client.post("/api/customers", json={"name": "DRCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "DRLoc", "customer_id": c_id}) loc_id = r.json()["id"] r = client.post("/api/rooms", json={"location_id": loc_id, "name": "DelRoom"}) room_id = r.json()["id"] r = client.delete(f"/api/rooms/{room_id}") assert r.status_code == 204 def test_delete_room_not_found(self, client): r = client.delete("/api/rooms/99999") assert r.status_code == 404 class TestListRooms: """GET /api/rooms — list rooms with optional location_id filter.""" @pytest.fixture def _setup(self, client): """Create customer, location, and two rooms. Returns location_id and room names.""" r = client.post("/api/customers", json={"name": "LR Cust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "LR Loc", "customer_id": c_id}) loc_id = r.json()["id"] client.post("/api/rooms", json={"location_id": loc_id, "name": "Room A"}) client.post("/api/rooms", json={"location_id": loc_id, "name": "Room B"}) # Second location with one room r = client.post("/api/locations", json={"name": "LR Loc2", "customer_id": c_id}) loc2_id = r.json()["id"] client.post("/api/rooms", json={"location_id": loc2_id, "name": "Room C"}) return loc_id, loc2_id def test_list_all_rooms_returns_array(self, client): """Listing rooms returns an array.""" r = client.get("/api/rooms") assert r.status_code == 200 assert isinstance(r.json(), list) def test_list_rooms_includes_created(self, client, _setup): """Created rooms appear in the list.""" r = client.get("/api/rooms") names = {room["name"] for room in r.json()} assert "Room A" in names assert "Room B" in names assert "Room C" in names def test_list_rooms_filter_by_location(self, client, _setup): """Filtering by location_id returns only rooms for that location.""" loc_id, _ = _setup r = client.get(f"/api/rooms?location_id={loc_id}") assert r.status_code == 200 names = {room["name"] for room in r.json()} assert names == {"Room A", "Room B"} def test_list_rooms_empty_for_nonexistent_location(self, client): """Filtering by nonexistent location returns empty list.""" r = client.get("/api/rooms?location_id=99999") assert r.status_code == 200 assert r.json() == [] def test_list_rooms_returns_room_fields(self, client, _setup): """Each room has expected fields.""" r = client.get("/api/rooms") room = r.json()[0] for field in ("id", "location_id", "name", "floor", "created_at", "updated_at"): assert field in room class TestGetRoom: """GET /api/rooms/{id} — get a single room.""" def test_get_room_returns_200(self, client): """Get an existing room returns 200 with room data.""" r = client.post("/api/customers", json={"name": "GR Cust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "GR Loc", "customer_id": c_id}) loc_id = r.json()["id"] r = client.post("/api/rooms", json={"location_id": loc_id, "name": "GetMe"}) room_id = r.json()["id"] r = client.get(f"/api/rooms/{room_id}") assert r.status_code == 200 data = r.json() assert data["name"] == "GetMe" assert data["location_id"] == loc_id assert data["id"] == room_id def test_get_room_not_found_returns_404(self, client): """Get a nonexistent room returns 404.""" r = client.get("/api/rooms/99999") assert r.status_code == 404 assert "Room not found" in r.json()["detail"] # ─── Phase B: Settings API ──────────────────────────────────────────────── def _settings_crud_tests(entity, create_payload, update_payload, update_check_key, update_check_val): """Generate CRUD test classes for a settings entity.""" class TestCreate: def test_create_returns_201(self, client): r = client.post(f"/api/settings/{entity}", json=create_payload) assert r.status_code == 201 data = r.json() assert "id" in data def test_create_duplicate_name_rejected(self, client): client.post(f"/api/settings/{entity}", json=create_payload) r = client.post(f"/api/settings/{entity}", json=create_payload) assert r.status_code == 409 class TestList: def test_list_returns_array(self, client): r = client.get(f"/api/settings/{entity}") assert r.status_code == 200 assert isinstance(r.json(), list) def test_list_includes_created(self, client): client.post(f"/api/settings/{entity}", json=create_payload) r = client.get(f"/api/settings/{entity}") assert len(r.json()) >= 1 class TestUpdate: def test_update_returns_200(self, client): r = client.post(f"/api/settings/{entity}", json=create_payload) eid = r.json()["id"] r = client.put(f"/api/settings/{entity}/{eid}", json=update_payload) assert r.status_code == 200 if update_check_key: assert r.json()[update_check_key] == update_check_val def test_update_not_found(self, client): r = client.put(f"/api/settings/{entity}/99999", json=update_payload) assert r.status_code == 404 class TestDelete: def test_delete_returns_204(self, client): r = client.post(f"/api/settings/{entity}", json=create_payload) eid = r.json()["id"] r = client.delete(f"/api/settings/{entity}/{eid}") assert r.status_code == 204 def test_delete_not_found(self, client): r = client.delete(f"/api/settings/{entity}/99999") assert r.status_code == 404 class TestGet: def test_get_returns_200(self, client): r = client.post(f"/api/settings/{entity}", json=create_payload) eid = r.json()["id"] r = client.get(f"/api/settings/{entity}/{eid}") assert r.status_code == 200 assert r.json()["id"] == eid def test_get_not_found_returns_404(self, client): r = client.get(f"/api/settings/{entity}/99999") assert r.status_code == 404 return TestCreate, TestList, TestUpdate, TestDelete, TestGet # Generate test classes for each settings entity _TestCatCreate, _TestCatList, _TestCatUpdate, _TestCatDelete, _TestCatGet = _settings_crud_tests( "categories", {"name": "TestCategory", "icon": "🧪"}, {"name": "UpdatedCategory"}, "name", "UpdatedCategory", ) _TestMakeCreate, _TestMakeList, _TestMakeUpdate, _TestMakeDelete, _TestMakeGet = _settings_crud_tests( "makes", {"name": "TestMake"}, {"name": "UpdatedMake"}, "name", "UpdatedMake", ) _TestKeyNameCreate, _TestKeyNameList, _TestKeyNameUpdate, _TestKeyNameDelete, _TestKeyNameGet = _settings_crud_tests( "key_names", {"name": "TestKey"}, {"name": "UpdatedKey"}, "name", "UpdatedKey", ) _TestKeyTypeCreate, _TestKeyTypeList, _TestKeyTypeUpdate, _TestKeyTypeDelete, _TestKeyTypeGet = _settings_crud_tests( "key_types", {"name": "TestType"}, {"name": "UpdatedType"}, "name", "UpdatedType", ) _TestBadgeTypeCreate, _TestBadgeTypeList, _TestBadgeTypeUpdate, _TestBadgeTypeDelete, _TestBadgeTypeGet = _settings_crud_tests( "badge_types", {"name": "TestBadge"}, {"name": "UpdatedBadge"}, "name", "UpdatedBadge", ) class TestSettingsCategoriesCreate(_TestCatCreate): pass class TestSettingsCategoriesList(_TestCatList): pass class TestSettingsCategoriesUpdate(_TestCatUpdate): pass class TestSettingsCategoriesDelete(_TestCatDelete): pass class TestSettingsCategoriesGet(_TestCatGet): pass class TestSettingsMakesCreate(_TestMakeCreate): pass class TestSettingsMakesList(_TestMakeList): pass class TestSettingsMakesUpdate(_TestMakeUpdate): pass class TestSettingsMakesDelete(_TestMakeDelete): pass class TestSettingsMakesGet(_TestMakeGet): pass class TestSettingsKeyNamesCreate(_TestKeyNameCreate): pass class TestSettingsKeyNamesList(_TestKeyNameList): pass class TestSettingsKeyNamesUpdate(_TestKeyNameUpdate): pass class TestSettingsKeyNamesDelete(_TestKeyNameDelete): pass class TestSettingsKeyNamesGet(_TestKeyNameGet): pass class TestSettingsKeyTypesCreate(_TestKeyTypeCreate): pass class TestSettingsKeyTypesList(_TestKeyTypeList): pass class TestSettingsKeyTypesUpdate(_TestKeyTypeUpdate): pass class TestSettingsKeyTypesDelete(_TestKeyTypeDelete): pass class TestSettingsKeyTypesGet(_TestKeyTypeGet): pass class TestSettingsBadgeTypesCreate(_TestBadgeTypeCreate): pass class TestSettingsBadgeTypesList(_TestBadgeTypeList): pass class TestSettingsBadgeTypesUpdate(_TestBadgeTypeUpdate): pass class TestSettingsBadgeTypesDelete(_TestBadgeTypeDelete): pass class TestSettingsBadgeTypesGet(_TestBadgeTypeGet): pass class TestSettingsModels: """Models entity has FK to makes — requires special create/update.""" @pytest.fixture def make_id(self, client): r = client.post("/api/settings/makes", json={"name": "ModelMake"}) return r.json()["id"] def test_create_model_returns_201(self, client, make_id): payload = {"make_id": make_id, "name": "Model X"} r = client.post("/api/settings/models", json=payload) assert r.status_code == 201 data = r.json() assert data["name"] == "Model X" assert data["make_id"] == make_id def test_create_model_requires_make_id(self, client): r = client.post("/api/settings/models", json={"name": "No Make"}) assert r.status_code == 422 def test_list_models_returns_array(self, client): r = client.get("/api/settings/models") assert r.status_code == 200 assert isinstance(r.json(), list) def test_update_model_name(self, client, make_id): r = client.post("/api/settings/models", json={"make_id": make_id, "name": "OldModel"}) mid = r.json()["id"] r = client.put(f"/api/settings/models/{mid}", json={"name": "NewModel"}) assert r.status_code == 200 assert r.json()["name"] == "NewModel" def test_delete_model_returns_204(self, client, make_id): r = client.post("/api/settings/models", json={"make_id": make_id, "name": "DelModel"}) mid = r.json()["id"] r = client.delete(f"/api/settings/models/{mid}") assert r.status_code == 204 def test_models_not_found(self, client): r = client.get("/api/settings/models/99999") assert r.status_code == 404 def test_get_model_returns_200(self, client, make_id): """GET /api/settings/models/{id} returns the model.""" r = client.post("/api/settings/models", json={"make_id": make_id, "name": "GetModel"}) mid = r.json()["id"] r = client.get(f"/api/settings/models/{mid}") assert r.status_code == 200 assert r.json()["name"] == "GetModel" assert r.json()["id"] == mid # ─── Phase B: Enhanced Asset Endpoints ──────────────────────────────────── class TestCreateAssetWithKeysAndBadges: """POST /api/assets with keys[] and badges[] arrays.""" def test_create_asset_with_keys(self, client): payload = { "machine_id": "KEY001", "name": "Asset With Keys", "keys": [ {"key_name": "MK500", "key_type": "Round Short"}, {"key_name": "Green Dot", "key_type": "Standard"}, ], } r = client.post("/api/assets", json=payload) assert r.status_code == 201 data = r.json() assert len(data["keys"]) == 2 assert data["keys"][0]["key_name"] == "MK500" def test_create_asset_with_badges(self, client): payload = { "machine_id": "BADGE001", "name": "Asset With Badges", "badges": ["Disney Contractor Base", "Visitor Badge"], } r = client.post("/api/assets", json=payload) assert r.status_code == 201 data = r.json() assert len(data["badges"]) == 2 assert data["badges"][0]["badge_name"] == "Disney Contractor Base" def test_create_asset_keys_default_empty(self, client): payload = {"machine_id": "NOKEY001", "name": "No Keys"} r = client.post("/api/assets", json=payload) assert r.status_code == 201 assert r.json()["keys"] == [] def test_create_asset_badges_default_empty(self, client): payload = {"machine_id": "NOBADGE001", "name": "No Badges"} r = client.post("/api/assets", json=payload) assert r.status_code == 201 assert r.json()["badges"] == [] class TestGetAssetDetail: """GET /api/assets/{id} — detail with keys, badges, customer/location names.""" def test_get_asset_includes_keys(self, client): payload = { "machine_id": "DETAIL001", "name": "Detail Asset", "keys": [{"key_name": "MK500", "key_type": "Round Short"}], } r = client.post("/api/assets", json=payload) asset_id = r.json()["id"] r = client.get(f"/api/assets/{asset_id}") assert r.status_code == 200 data = r.json() assert "keys" in data assert len(data["keys"]) == 1 def test_get_asset_includes_badges(self, client): payload = { "machine_id": "DETAIL002", "name": "Badge Asset", "badges": ["Disney Contractor Base"], } r = client.post("/api/assets", json=payload) asset_id = r.json()["id"] r = client.get(f"/api/assets/{asset_id}") data = r.json() assert "badges" in data assert len(data["badges"]) == 1 def test_get_asset_includes_customer_name(self, client): r = client.post("/api/customers", json={"name": "DetailCustomer"}) cust_id = r.json()["id"] payload = {"machine_id": "DETAIL003", "name": "Customer Asset", "customer_id": cust_id} r = client.post("/api/assets", json=payload) asset_id = r.json()["id"] r = client.get(f"/api/assets/{asset_id}") data = r.json() assert data.get("customer_name") == "DetailCustomer" def test_get_asset_includes_location_name(self, client): r = client.post("/api/customers", json={"name": "LocCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "DetailLocation", "customer_id": c_id}) loc_id = r.json()["id"] payload = {"machine_id": "DETAIL004", "name": "Location Asset", "location_id": loc_id} r = client.post("/api/assets", json=payload) asset_id = r.json()["id"] r = client.get(f"/api/assets/{asset_id}") data = r.json() assert data.get("location_name") == "DetailLocation" class TestListAssetsExtendedFilters: """GET /api/assets with extended v2 filters.""" def test_list_assets_filter_by_make(self, client): client.post("/api/assets", json={"machine_id": "F1", "name": "A1", "make": "Hobart"}) client.post("/api/assets", json={"machine_id": "F2", "name": "A2", "make": "Vollrath"}) r = client.get("/api/assets?make=Hobart") data = r.json() assert len(data) == 1 assert data[0]["make"] == "Hobart" def test_list_assets_filter_by_model(self, client): client.post("/api/assets", json={"machine_id": "F3", "name": "A3", "model": "HLX-200"}) client.post("/api/assets", json={"machine_id": "F4", "name": "A4", "model": "VX-500"}) r = client.get("/api/assets?model=VX-500") data = r.json() assert len(data) == 1 assert data[0]["model"] == "VX-500" def test_list_assets_filter_by_customer_id(self, client): r = client.post("/api/customers", json={"name": "FilterCust"}) cid = r.json()["id"] client.post("/api/assets", json={"machine_id": "F5", "name": "CustAsset", "customer_id": cid}) client.post("/api/assets", json={"machine_id": "F6", "name": "NoCust"}) r = client.get(f"/api/assets?customer_id={cid}") data = r.json() assert len(data) == 1 def test_list_assets_filter_by_location_id(self, client): r = client.post("/api/customers", json={"name": "FLocCust"}) c_id = r.json()["id"] r = client.post("/api/locations", json={"name": "FLoc", "customer_id": c_id}) loc_id = r.json()["id"] client.post("/api/assets", json={"machine_id": "F7", "name": "LocAsset", "location_id": loc_id}) client.post("/api/assets", json={"machine_id": "F8", "name": "NoLoc"}) r = client.get(f"/api/assets?location_id={loc_id}") data = r.json() assert len(data) == 1 def test_list_assets_filter_by_assigned_to(self, client): client.post("/api/assets", json={"machine_id": "F9", "name": "Assigned", "assigned_to": 1}) client.post("/api/assets", json={"machine_id": "F10", "name": "Unassigned"}) r = client.get("/api/assets?assigned_to=1") data = r.json() assert len(data) == 1 def test_list_assets_multiple_filters(self, client): client.post("/api/assets", json={"machine_id": "F11", "name": "Match", "category": "Equipment", "make": "Hobart"}) client.post("/api/assets", json={"machine_id": "F12", "name": "NoMatch", "category": "Equipment", "make": "Vollrath"}) r = client.get("/api/assets?category=Equipment&make=Hobart") data = r.json() assert len(data) == 1 assert data[0]["name"] == "Match" # ─── Phase C: Users API ────────────────────────────────────────────────── class TestCreateUser: """POST /api/users — admin creates user accounts.""" def test_create_user_returns_201(self, client): payload = {"username": "tech1", "password": "secret123", "role": "technician"} r = client.post("/api/users", json=payload) assert r.status_code == 201 data = r.json() assert data["username"] == "tech1" assert data["role"] == "technician" assert "password_hash" not in data def test_create_user_default_role_technician(self, client): payload = {"username": "tech2", "password": "pass456"} r = client.post("/api/users", json=payload) assert r.status_code == 201 assert r.json()["role"] == "technician" def test_create_user_requires_username(self, client): r = client.post("/api/users", json={"password": "pw"}) assert r.status_code == 422 def test_create_user_requires_password(self, client): r = client.post("/api/users", json={"username": "u1"}) assert r.status_code == 422 def test_create_user_duplicate_username_rejected(self, client): client.post("/api/users", json={"username": "dup", "password": "pw1"}) r = client.post("/api/users", json={"username": "dup", "password": "pw2"}) assert r.status_code == 409 def test_create_user_invalid_role_rejected(self, client): r = client.post("/api/users", json={"username": "bad", "password": "pw", "role": "superadmin"}) assert r.status_code == 422 class TestListUsers: """GET /api/users — list all users.""" def test_list_users_includes_admin(self, client): r = client.get("/api/users") assert r.status_code == 200 data = r.json() assert isinstance(data, list) usernames = {u["username"] for u in data} assert "admin" in usernames def test_list_users_returns_created(self, client): client.post("/api/users", json={"username": "listme", "password": "pw"}) r = client.get("/api/users") usernames = {u["username"] for u in r.json()} assert "listme" in usernames def test_list_users_no_passwords(self, client): r = client.get("/api/users") for u in r.json(): assert "password_hash" not in u class TestGetUser: """GET /api/users/{id} — user detail.""" def test_get_user_returns_200(self, client): r = client.post("/api/users", json={"username": "detail", "password": "pw"}) uid = r.json()["id"] r = client.get(f"/api/users/{uid}") assert r.status_code == 200 assert r.json()["username"] == "detail" def test_get_user_not_found(self, client): r = client.get("/api/users/99999") assert r.status_code == 404 def test_get_user_no_password(self, client): r = client.post("/api/users", json={"username": "safe", "password": "pw"}) uid = r.json()["id"] r = client.get(f"/api/users/{uid}") assert "password_hash" not in r.json() class TestUpdateUser: """PUT /api/users/{id} — update user.""" def test_update_user_role(self, client): r = client.post("/api/users", json={"username": "upgrade", "password": "pw"}) uid = r.json()["id"] r = client.put(f"/api/users/{uid}", json={"role": "admin"}) assert r.status_code == 200 assert r.json()["role"] == "admin" def test_update_user_password(self, client): r = client.post("/api/users", json={"username": "pwchange", "password": "old"}) uid = r.json()["id"] r = client.put(f"/api/users/{uid}", json={"password": "newpassword"}) assert r.status_code == 200 def test_update_user_not_found(self, client): r = client.put("/api/users/99999", json={"role": "admin"}) assert r.status_code == 404 def test_update_user_invalid_role(self, client): r = client.post("/api/users", json={"username": "badrole", "password": "pw"}) uid = r.json()["id"] r = client.put(f"/api/users/{uid}", json={"role": "invalid"}) assert r.status_code == 422 class TestDeleteUser: """DELETE /api/users/{id} — delete user.""" def test_delete_user_returns_204(self, client): r = client.post("/api/users", json={"username": "delme", "password": "pw"}) uid = r.json()["id"] r = client.delete(f"/api/users/{uid}") assert r.status_code == 204 def test_delete_user_not_found(self, client): r = client.delete("/api/users/99999") assert r.status_code == 404 def test_delete_user_gone_after_delete(self, client): r = client.post("/api/users", json={"username": "gone", "password": "pw"}) uid = r.json()["id"] client.delete(f"/api/users/{uid}") r = client.get(f"/api/users/{uid}") assert r.status_code == 404 # ─── Phase C: Auth API ─────────────────────────────────────────────────── class TestAuthLogin: """POST /api/auth/login — simple password auth.""" def test_login_admin_returns_token(self, client): r = client.post("/api/auth/login", json={"username": "admin", "password": "changeme"}) assert r.status_code == 200 data = r.json() assert data["username"] == "admin" assert data["role"] == "admin" assert "token" in data assert "password_hash" not in data def test_login_created_user(self, client): client.post("/api/users", json={"username": "logger", "password": "mypassword"}) r = client.post("/api/auth/login", json={"username": "logger", "password": "mypassword"}) assert r.status_code == 200 assert r.json()["username"] == "logger" assert r.json()["role"] == "technician" def test_login_wrong_password_returns_401(self, client): r = client.post("/api/auth/login", json={"username": "admin", "password": "wrong"}) assert r.status_code == 401 def test_login_nonexistent_user_returns_401(self, client): r = client.post("/api/auth/login", json={"username": "nobody", "password": "pw"}) assert r.status_code == 401 def test_login_requires_username(self, client): r = client.post("/api/auth/login", json={"password": "pw"}) assert r.status_code == 422 def test_login_requires_password(self, client): r = client.post("/api/auth/login", json={"username": "admin"}) assert r.status_code == 422 def test_login_updated_password(self, client): r = client.post("/api/users", json={"username": "upw", "password": "first"}) uid = r.json()["id"] client.put(f"/api/users/{uid}", json={"password": "second"}) r = client.post("/api/auth/login", json={"username": "upw", "password": "first"}) assert r.status_code == 401 r = client.post("/api/auth/login", json={"username": "upw", "password": "second"}) assert r.status_code == 200 # ─── Phase C: Check-ins Extension ──────────────────────────────────────── class TestCheckinWithUser: """POST /api/checkins — with user_id field.""" @pytest.fixture def asset_id(self, client): r = client.post("/api/assets", json={"machine_id": "CHKU001", "name": "User Checkin"}) return r.json()["id"] @pytest.fixture def user_id(self, client): r = client.post("/api/users", json={"username": "checker", "password": "pw"}) return r.json()["id"] def test_create_checkin_with_user_id(self, client, asset_id, user_id): payload = {"asset_id": asset_id, "user_id": user_id, "notes": "Checked by user"} r = client.post("/api/checkins", json=payload) assert r.status_code == 201 assert r.json()["user_id"] == user_id def test_create_checkin_without_user_id_allowed(self, client, asset_id): payload = {"asset_id": asset_id, "notes": "No user"} r = client.post("/api/checkins", json=payload) assert r.status_code == 201 assert r.json()["user_id"] is None class TestListCheckinsByUser: """GET /api/checkins — filter by user_id.""" def test_filter_checkins_by_user_id(self, client): r = client.post("/api/users", json={"username": "filterme", "password": "pw"}) uid = r.json()["id"] r = client.post("/api/assets", json={"machine_id": "CKU1", "name": "A1"}) a1 = r.json()["id"] r = client.post("/api/assets", json={"machine_id": "CKU2", "name": "A2"}) a2 = r.json()["id"] client.post("/api/checkins", json={"asset_id": a1, "user_id": uid, "notes": "From uid"}) client.post("/api/checkins", json={"asset_id": a2, "notes": "No user"}) r = client.get(f"/api/checkins?user_id={uid}") data = r.json() assert len(data) == 1 assert data[0]["notes"] == "From uid" # ─── Phase C: Checkin CRUD (get/update/delete) ──────────────────────────── class TestGetCheckin: """GET /api/checkins/{id} — get single checkin.""" @pytest.fixture def checkin_id(self, client): r = client.post("/api/assets", json={"machine_id": "GETCK001", "name": "Get Checkin"}) aid = r.json()["id"] r = client.post("/api/checkins", json={"asset_id": aid, "notes": "Test checkin", "latitude": 40.0, "longitude": -74.0}) return r.json()["id"] def test_get_checkin_returns_200(self, client, checkin_id): r = client.get(f"/api/checkins/{checkin_id}") assert r.status_code == 200 assert r.json()["notes"] == "Test checkin" assert r.json()["latitude"] == 40.0 def test_get_checkin_not_found_returns_404(self, client): r = client.get("/api/checkins/99999") assert r.status_code == 404 assert "Checkin not found" in r.json()["detail"] def test_get_checkin_has_all_fields(self, client, checkin_id): r = client.get(f"/api/checkins/{checkin_id}") data = r.json() for field in ("id", "asset_id", "latitude", "longitude", "accuracy", "photo_path", "notes", "user_id", "created_at"): assert field in data class TestUpdateCheckin: """PUT /api/checkins/{id} — update checkin fields.""" @pytest.fixture def checkin_id(self, client): r = client.post("/api/assets", json={"machine_id": "UPCK001", "name": "Update Checkin"}) aid = r.json()["id"] r = client.post("/api/checkins", json={"asset_id": aid, "notes": "Original", "latitude": 40.0, "longitude": -74.0}) return r.json()["id"] def test_update_checkin_notes(self, client, checkin_id): r = client.put(f"/api/checkins/{checkin_id}", json={"notes": "Updated notes"}) assert r.status_code == 200 assert r.json()["notes"] == "Updated notes" def test_update_checkin_location(self, client, checkin_id): r = client.put(f"/api/checkins/{checkin_id}", json={"latitude": 34.0522, "longitude": -118.2437}) assert r.status_code == 200 data = r.json() assert data["latitude"] == 34.0522 assert data["longitude"] == -118.2437 def test_update_checkin_accuracy(self, client, checkin_id): r = client.put(f"/api/checkins/{checkin_id}", json={"accuracy": 5.5}) assert r.status_code == 200 assert r.json()["accuracy"] == 5.5 def test_update_checkin_user_id(self, client, checkin_id): r = client.put(f"/api/checkins/{checkin_id}", json={"user_id": 1}) assert r.status_code == 200 assert r.json()["user_id"] == 1 def test_update_checkin_partial_preserves_other_fields(self, client, checkin_id): """Updating one field should not change others.""" r = client.put(f"/api/checkins/{checkin_id}", json={"notes": "New note only"}) assert r.status_code == 200 data = r.json() assert data["notes"] == "New note only" assert data["latitude"] == 40.0 # unchanged def test_update_checkin_not_found_returns_404(self, client): r = client.put("/api/checkins/99999", json={"notes": "Nope"}) assert r.status_code == 404 assert "Checkin not found" in r.json()["detail"] def test_update_checkin_empty_body_no_change(self, client, checkin_id): """Sending empty body should not error — returns current state.""" r = client.put(f"/api/checkins/{checkin_id}", json={}) assert r.status_code == 200 assert r.json()["notes"] == "Original" class TestDeleteCheckin: """DELETE /api/checkins/{id} — delete checkin.""" @pytest.fixture def checkin_id(self, client): r = client.post("/api/assets", json={"machine_id": "DELCK001", "name": "Delete Checkin"}) aid = r.json()["id"] r = client.post("/api/checkins", json={"asset_id": aid, "notes": "To delete"}) return r.json()["id"] def test_delete_checkin_returns_204(self, client, checkin_id): r = client.delete(f"/api/checkins/{checkin_id}") assert r.status_code == 204 def test_delete_checkin_gone_after_delete(self, client, checkin_id): client.delete(f"/api/checkins/{checkin_id}") r = client.get(f"/api/checkins/{checkin_id}") assert r.status_code == 404 def test_delete_checkin_not_found_returns_404(self, client): r = client.delete("/api/checkins/99999") assert r.status_code == 404 assert "Checkin not found" in r.json()["detail"] class TestCheckinCascade: """Checkin cascade: deleting an asset should cascade-delete its checkins.""" def test_delete_asset_cascades_checkins(self, client): r = client.post("/api/assets", json={"machine_id": "CASC001", "name": "Cascade Asset"}) aid = r.json()["id"] # Create multiple checkins client.post("/api/checkins", json={"asset_id": aid, "notes": "Checkin 1"}) client.post("/api/checkins", json={"asset_id": aid, "notes": "Checkin 2"}) # Verify checkins exist r = client.get(f"/api/checkins?asset_id={aid}") assert len(r.json()) == 2 # Delete the asset client.delete(f"/api/assets/{aid}") # Verify checkins are gone r = client.get(f"/api/checkins?asset_id={aid}") assert r.json() == [] # ─── Phase C: Geofences API ────────────────────────────────────────────── class TestCreateGeofence: """POST /api/geofences — create geofence area.""" def test_create_geofence_returns_201(self, client): payload = { "name": "Warehouse Zone", "points": [[40.7128, -74.0060], [40.7130, -74.0050], [40.7120, -74.0040]], "color": "#ff0000", } r = client.post("/api/geofences", json=payload) assert r.status_code == 201 data = r.json() assert data["name"] == "Warehouse Zone" assert data["color"] == "#ff0000" assert "id" in data def test_create_geofence_requires_name(self, client): r = client.post("/api/geofences", json={"points": [[0, 0]]}) assert r.status_code == 422 def test_create_geofence_requires_points(self, client): r = client.post("/api/geofences", json={"name": "Zone"}) assert r.status_code == 422 def test_create_geofence_requires_points_to_be_array_of_arrays(self, client): r = client.post("/api/geofences", json={"name": "Bad", "points": "not an array"}) assert r.status_code == 422 def test_create_geofence_default_color(self, client): payload = {"name": "Default Color", "points": [[0, 0], [1, 1]]} r = client.post("/api/geofences", json=payload) assert r.status_code == 201 assert r.json()["color"] == "#3388ff" class TestListGeofences: """GET /api/geofences — list all.""" def test_list_geofences_returns_array(self, client): r = client.get("/api/geofences") assert r.status_code == 200 assert isinstance(r.json(), list) def test_list_geofences_includes_created(self, client): client.post("/api/geofences", json={"name": "ListZone", "points": [[0, 0], [1, 1]]}) r = client.get("/api/geofences") data = r.json() assert len(data) == 1 assert data[0]["name"] == "ListZone" class TestUpdateGeofence: """PUT /api/geofences/{id} — update.""" def test_update_geofence_name(self, client): r = client.post("/api/geofences", json={"name": "OldZone", "points": [[0, 0], [1, 1]]}) gid = r.json()["id"] r = client.put(f"/api/geofences/{gid}", json={"name": "NewZone"}) assert r.status_code == 200 assert r.json()["name"] == "NewZone" def test_update_geofence_points(self, client): r = client.post("/api/geofences", json={"name": "MoveZone", "points": [[0, 0], [1, 1]]}) gid = r.json()["id"] new_points = [[10, 20], [30, 40], [50, 60]] r = client.put(f"/api/geofences/{gid}", json={"points": new_points}) assert r.status_code == 200 assert r.json()["points"] == new_points def test_update_geofence_not_found(self, client): r = client.put("/api/geofences/99999", json={"name": "Nope"}) assert r.status_code == 404 class TestDeleteGeofence: """DELETE /api/geofences/{id} — delete.""" def test_delete_geofence_returns_204(self, client): r = client.post("/api/geofences", json={"name": "DelZone", "points": [[0, 0], [1, 1]]}) gid = r.json()["id"] r = client.delete(f"/api/geofences/{gid}") assert r.status_code == 204 def test_delete_geofence_not_found(self, client): r = client.delete("/api/geofences/99999") assert r.status_code == 404 # ─── Phase C: Visits API ───────────────────────────────────────────────── class TestAutoVisitLogging: """Auto-visit logging: if user has check-ins at same asset within 10 min window.""" @pytest.fixture def setup_data(self, client): r = client.post("/api/users", json={"username": "visitor1", "password": "pw"}) uid = r.json()["id"] r = client.post("/api/assets", json={"machine_id": "VIS001", "name": "Visit Asset"}) aid = r.json()["id"] return uid, aid def test_auto_visit_logged_on_checkin(self, client, setup_data): uid, aid = setup_data # First checkin client.post("/api/checkins", json={"asset_id": aid, "user_id": uid}) # Second checkin same asset, same user — should trigger visit client.post("/api/checkins", json={"asset_id": aid, "user_id": uid}) r = client.get("/api/visits") visits = r.json() # A visit should be logged for the two checkins within a short window assert len(visits) >= 1 assert visits[0]["user_id"] == uid assert visits[0]["asset_id"] == aid def test_no_visit_for_single_checkin(self, client, setup_data): uid, aid = setup_data client.post("/api/checkins", json={"asset_id": aid, "user_id": uid}) r = client.get(f"/api/visits?asset_id={aid}") visits = r.json() # Single checkin — no visit logged (needs at least 2 in window) assert len(visits) == 0 class TestListVisits: """GET /api/visits — list visits with filters.""" def test_list_visits_returns_array(self, client): r = client.get("/api/visits") assert r.status_code == 200 assert isinstance(r.json(), list) def test_list_visits_filter_by_asset_id(self, client): r = client.get("/api/visits?asset_id=1") assert r.status_code == 200 def test_list_visits_filter_by_user_id(self, client): r = client.get("/api/visits?user_id=1") assert r.status_code == 200 def test_list_visits_filter_by_date_range(self, client): r = client.get("/api/visits?date_from=2024-01-01&date_to=2024-12-31") assert r.status_code == 200 def test_list_visits_pagination(self, client): r = client.get("/api/visits?limit=5&offset=0") assert r.status_code == 200 class TestVisitStats: """GET /api/visits/stats — aggregate visit data.""" def test_visit_stats_returns_json(self, client): r = client.get("/api/visits/stats") assert r.status_code == 200 data = r.json() assert isinstance(data, dict) assert "total_visits" in data def test_visit_stats_empty_state(self, client): data = client.get("/api/visits/stats").json() assert data["total_visits"] == 0 class TestCreateVisit: """POST /api/visits — manually log a visit.""" def test_create_visit_returns_201(self, client): r = client.post("/api/assets", json={"machine_id": "VST001", "name": "Visit Test Asset"}) aid = r.json()["id"] r = client.post("/api/visits", json={ "asset_id": aid, "user_id": None, "latitude": 40.7128, "longitude": -74.0060, "duration_minutes": 10, }) assert r.status_code == 201 data = r.json() assert data["asset_id"] == aid assert data["duration_minutes"] == 10 assert "id" in data def test_create_visit_requires_asset_id(self, client): r = client.post("/api/visits", json={"duration_minutes": 5}) assert r.status_code == 422 def test_create_visit_nonexistent_asset_returns_404(self, client): r = client.post("/api/visits", json={ "asset_id": 99999, "duration_minutes": 5, }) assert r.status_code == 404 def test_create_visit_appears_in_list(self, client): r = client.post("/api/assets", json={"machine_id": "VST002", "name": "List Visit Asset"}) aid = r.json()["id"] client.post("/api/visits", json={ "asset_id": aid, "user_id": None, "duration_minutes": 12, }) r = client.get("/api/visits") assert r.status_code == 200 visits = r.json() assert len(visits) >= 1 assert any(v["asset_id"] == aid and v["duration_minutes"] == 12 for v in visits) def test_create_visit_appears_in_stats(self, client): r = client.post("/api/assets", json={"machine_id": "VST003", "name": "Stats Visit Asset"}) aid = r.json()["id"] client.post("/api/visits", json={ "asset_id": aid, "user_id": None, "duration_minutes": 15, }) data = client.get("/api/visits/stats").json() assert data["total_visits"] >= 1 assert any(v["name"] == "Stats Visit Asset" and v["count"] >= 1 for v in data.get("visits_per_asset", [])) def test_create_visit_activity_logged(self, client): r = client.post("/api/assets", json={"machine_id": "VST004", "name": "Activity Visit Asset"}) aid = r.json()["id"] client.post("/api/visits", json={"asset_id": aid, "duration_minutes": 8}) r = client.get("/api/activity") data = r.json() assert any(e["entity_type"] == "visit" and e["action"] == "created" for e in data) # ─── Phase C: Activity Feed API ────────────────────────────────────────── class TestActivityLogging: """Activity is auto-logged on CRUD operations.""" def test_activity_logged_on_asset_create(self, client): client.post("/api/assets", json={"machine_id": "ACT001", "name": "Activity Asset"}) r = client.get("/api/activity") data = r.json() assert len(data) >= 1 assert any(e["entity_type"] == "asset" and e["action"] == "created" for e in data) def test_activity_logged_on_customer_create(self, client): client.post("/api/customers", json={"name": "ActivityCustomer"}) r = client.get("/api/activity") data = r.json() assert any(e["entity_type"] == "customer" and e["action"] == "created" for e in data) def test_activity_logged_on_checkin(self, client): r = client.post("/api/assets", json={"machine_id": "ACTCK1", "name": "Ck Asset"}) aid = r.json()["id"] client.post("/api/checkins", json={"asset_id": aid}) r = client.get("/api/activity") data = r.json() assert any(e["entity_type"] == "checkin" and e["action"] == "created" for e in data) class TestListActivity: """GET /api/activity — list with filters and pagination.""" def test_list_activity_returns_array(self, client): r = client.get("/api/activity") assert r.status_code == 200 assert isinstance(r.json(), list) def test_list_activity_filter_by_user_id(self, client): r = client.get("/api/activity?user_id=1") assert r.status_code == 200 def test_list_activity_filter_by_entity_type(self, client): r = client.get("/api/activity?entity_type=asset") assert r.status_code == 200 def test_list_activity_filter_by_date_range(self, client): r = client.get("/api/activity?date_from=2024-01-01&date_to=2024-12-31") assert r.status_code == 200 def test_list_activity_pagination(self, client): r = client.get("/api/activity?limit=10&offset=0") assert r.status_code == 200 def test_list_activity_ordered_newest_first(self, client): r = client.get("/api/activity") data = r.json() if len(data) >= 2: assert data[0]["id"] >= data[1]["id"] # ─── Phase C: Enhanced Stats API ───────────────────────────────────────── class TestEnhancedStats: """GET /api/stats — extended with top_visited, time_on_site, by_make.""" def test_enhanced_stats_has_top_visited(self, client): data = client.get("/api/stats").json() assert "top_visited" in data def test_enhanced_stats_has_time_on_site(self, client): data = client.get("/api/stats").json() assert "time_on_site" in data def test_enhanced_stats_has_by_make(self, client): data = client.get("/api/stats").json() assert "by_make" in data def test_enhanced_stats_by_make_reflects_data(self, client): client.post("/api/assets", json={"machine_id": "SM1", "name": "M1", "make": "Hobart"}) client.post("/api/assets", json={"machine_id": "SM2", "name": "M2", "make": "Hobart"}) client.post("/api/assets", json={"machine_id": "SM3", "name": "M3", "make": "Vollrath"}) data = client.get("/api/stats").json() by_make = data.get("by_make", {}) assert by_make.get("Hobart") == 2 assert by_make.get("Vollrath") == 1 # ─── Phase C: Export Extensions ────────────────────────────────────────── class TestExportExtensions: """Extended export endpoints.""" def test_export_assets_includes_v2_fields(self, client): client.post("/api/assets", json={ "machine_id": "EXPV2001", "name": "Export V2", "serial_number": "SN-999", "make": "Hobart", "model": "HLX-200", }) r = client.get("/api/export/assets") body = r.text assert "serial_number" in body assert "make" in body assert "model" in body def test_export_checkins_includes_user_id(self, client): r = client.post("/api/users", json={"username": "exporter", "password": "pw"}) uid = r.json()["id"] r = client.post("/api/assets", json={"machine_id": "EXPCK99", "name": "Export CK"}) aid = r.json()["id"] client.post("/api/checkins", json={"asset_id": aid, "user_id": uid, "notes": "export test"}) r = client.get("/api/export/checkins") body = r.text assert "user_id" in body def test_export_service_summary_returns_csv(self, client): client.post("/api/customers", json={"name": "ServiceCust"}) client.post("/api/assets", json={"machine_id": "EXPSVC1", "name": "Svc Asset"}) r = client.get("/api/export/service-summary") assert r.status_code == 200 assert "text/csv" in r.headers["content-type"] # ─── Phase D: File Upload Endpoints ──────────────────────────────────────── import tempfile as _tempfile_mod import shutil as _shutil_mod _UPLOADS_TMP = Path(_tempfile_mod.mkdtemp(prefix="canteen_test_uploads_")) os.environ["CANTEEN_UPLOADS_DIR"] = str(_UPLOADS_TMP) def pytest_sessionfinish(session): """Clean up temp uploads dir at session end.""" if _UPLOADS_TMP.exists(): _shutil_mod.rmtree(_UPLOADS_TMP, ignore_errors=True) class TestUploadIcon: """Phase D — icon upload (PNG / SVG / JPG, max 2 MB).""" @staticmethod def _make_file(name: str, content: bytes, mime: str = "image/png"): import io return {"file": (name, io.BytesIO(content), mime)} def test_upload_png_returns_201_and_path(self, client): files = self._make_file("icon.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 200) r = client.post("/api/upload/icon", files=files) assert r.status_code == 201 data = r.json() assert "path" in data assert data["path"].startswith("/uploads/") def test_upload_png_file_saved_to_disk(self, client): files = self._make_file("icon.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR" + b"\x00" * 500) r = client.post("/api/upload/icon", files=files) data = r.json() fname = data["path"].split("/")[-1] from server import UPLOADS_DIR saved = UPLOADS_DIR / "icons" / fname assert saved.exists() assert saved.stat().st_size > 0 def test_upload_svg_accepted(self, client): svg = b'' files = self._make_file("icon.svg", svg, "image/svg+xml") r = client.post("/api/upload/icon", files=files) assert r.status_code == 201 def test_upload_jpg_accepted(self, client): # Minimal JPEG header bytes jpg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 200 files = self._make_file("icon.jpg", jpg, "image/jpeg") r = client.post("/api/upload/icon", files=files) assert r.status_code == 201 def test_rejects_txt_file(self, client): files = self._make_file("readme.txt", b"hello world", "text/plain") r = client.post("/api/upload/icon", files=files) assert r.status_code == 400 def test_rejects_no_extension(self, client): files = self._make_file("icon", b"\x89PNG\r\n\x1a\n\x00\x00\x00\r", "image/png") r = client.post("/api/upload/icon", files=files) assert r.status_code == 400 def test_rejects_oversized_file(self, client): # > 2 MB big = b"\x89PNG\r\n\x1a\n" + b"\x00" * (2 * 1024 * 1024 + 1) files = self._make_file("big.png", big) r = client.post("/api/upload/icon", files=files) assert r.status_code == 413 def test_rejects_missing_file(self, client): r = client.post("/api/upload/icon") assert r.status_code == 422 class TestUploadPhoto: """Phase D — photo upload (JPEG / PNG, max 10 MB).""" @staticmethod def _make_file(name: str, content: bytes, mime: str = "image/jpeg"): import io return {"file": (name, io.BytesIO(content), mime)} def test_upload_jpg_returns_201_and_path(self, client): jpg = b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01" + b"\x00" * 200 files = self._make_file("photo.jpg", jpg, "image/jpeg") r = client.post("/api/upload/photo", files=files) assert r.status_code == 201 data = r.json() assert "path" in data assert data["path"].startswith("/uploads/") def test_upload_png_accepted(self, client): png = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 files = self._make_file("photo.png", png, "image/png") r = client.post("/api/upload/photo", files=files) assert r.status_code == 201 def test_rejects_gif(self, client): gif = b"GIF89a" + b"\x00" * 100 files = self._make_file("photo.gif", gif, "image/gif") r = client.post("/api/upload/photo", files=files) assert r.status_code == 400 def test_rejects_oversized_file(self, client): # > 10 MB big = b"\xff\xd8\xff\xe0" + b"\x00" * (10 * 1024 * 1024 + 1) files = self._make_file("big.jpg", big, "image/jpeg") r = client.post("/api/upload/photo", files=files) assert r.status_code == 413 def test_rejects_missing_file(self, client): r = client.post("/api/upload/photo") assert r.status_code == 422 class TestServeUploads: """Phase D — static file serving from /uploads/{filename}.""" def test_serve_uploaded_file(self, client): import io content = b"\x89PNG\r\n\x1a\n" + b"\x00" * 200 files = {"file": ("icon.png", io.BytesIO(content), "image/png")} r = client.post("/api/upload/icon", files=files) data = r.json() path = data["path"] # GET the served file r2 = client.get(path) assert r2.status_code == 200 assert r2.content == content def test_serve_nonexistent_file_returns_404(self, client): r = client.get("/uploads/nonexistent_12345.png") assert r.status_code == 404 # ─── Phase E: Auth Enforcement Tests ─────────────────────────────────────── class TestAuthRequired: """Verify that unauthenticated requests to protected endpoints return 401.""" def test_unauth_assets_list_returns_401(self, unauth_client): r = unauth_client.get("/api/assets") assert r.status_code == 401 def test_unauth_assets_create_returns_401(self, unauth_client): r = unauth_client.post("/api/assets", json={"machine_id": "X", "name": "X"}) assert r.status_code == 401 def test_unauth_assets_update_returns_401(self, unauth_client): r = unauth_client.put("/api/assets/1", json={"name": "X"}) assert r.status_code == 401 def test_unauth_assets_delete_returns_401(self, unauth_client): r = unauth_client.delete("/api/assets/1") assert r.status_code == 401 def test_unauth_customers_returns_401(self, unauth_client): r = unauth_client.get("/api/customers") assert r.status_code == 401 def test_unauth_locations_returns_401(self, unauth_client): r = unauth_client.get("/api/locations") assert r.status_code == 401 def test_unauth_users_returns_401(self, unauth_client): r = unauth_client.get("/api/users") assert r.status_code == 401 def test_unauth_geofences_returns_401(self, unauth_client): r = unauth_client.get("/api/geofences") assert r.status_code == 401 def test_unauth_stats_returns_401(self, unauth_client): r = unauth_client.get("/api/stats") assert r.status_code == 401 def test_unauth_checkins_returns_401(self, unauth_client): r = unauth_client.get("/api/checkins") assert r.status_code == 401 def test_unauth_activity_returns_401(self, unauth_client): r = unauth_client.get("/api/activity") assert r.status_code == 401 def test_unauth_visits_returns_401(self, unauth_client): r = unauth_client.get("/api/visits") assert r.status_code == 401 def test_unauth_upload_returns_401(self, unauth_client): import io files = {"file": ("icon.png", io.BytesIO(b"\x89PNG\r\n\x1a\n\x00" * 50), "image/png")} r = unauth_client.post("/api/upload/icon", files=files) assert r.status_code == 401 def test_unauth_export_returns_401(self, unauth_client): r = unauth_client.get("/api/export/assets") assert r.status_code == 401 def test_health_remains_public(self, unauth_client): r = unauth_client.get("/health") assert r.status_code == 200 def test_login_remains_public(self, unauth_client): r = unauth_client.post("/api/auth/login", json={"username": "admin", "password": "changeme"}) assert r.status_code == 200 def test_unauth_with_bad_header_returns_401(self, unauth_client): r = unauth_client.get("/api/assets", headers={"Authorization": "Bearer invalidtoken123"}) assert r.status_code == 401 def test_unauth_with_malformed_header_returns_401(self, unauth_client): r = unauth_client.get("/api/assets", headers={"Authorization": "Basic dXNlcjpwYXNz"}) assert r.status_code == 401 def test_unauth_with_no_header_returns_401(self, unauth_client): r = unauth_client.get("/api/assets") assert r.status_code == 401 assert "Authentication required" in r.json()["detail"] def test_unauth_with_expired_token_returns_401(self, unauth_client): r = unauth_client.get("/api/assets", headers={"Authorization": "Bearer " + "a" * 64}) assert r.status_code == 401 assert "Invalid or expired token" in r.json()["detail"] def test_authenticated_assets_works(self, auth_client): """Sanity check: authenticated requests work.""" r = auth_client.get("/api/assets") assert r.status_code == 200 class TestAuthMe: """GET /api/auth/me — returns current authenticated user.""" def test_auth_me_returns_user_with_token(self, auth_client): r = auth_client.get("/api/auth/me") assert r.status_code == 200 data = r.json() assert data["username"] == "admin" assert data["role"] == "admin" assert "password_hash" not in data def test_auth_me_unauth_returns_401(self, unauth_client): r = unauth_client.get("/api/auth/me") assert r.status_code == 401 def test_auth_me_bad_token_returns_401(self, unauth_client): r = unauth_client.get("/api/auth/me", headers={"Authorization": "Bearer invalidtoken123456"}) assert r.status_code == 401 assert "Invalid or expired token" in r.json()["detail"] def test_auth_me_missing_header_returns_401(self, unauth_client): r = unauth_client.get("/api/auth/me") assert r.status_code == 401 assert "Authentication required" in r.json()["detail"] def test_auth_me_malformed_header_returns_401(self, unauth_client): r = unauth_client.get("/api/auth/me", headers={"Authorization": "NotBearer token"}) assert r.status_code == 401 class TestAuthEnforcementExtra: """Additional auth enforcement edge cases.""" def test_checkin_get_unauth_returns_401(self, unauth_client): r = unauth_client.get("/api/checkins/1") assert r.status_code == 401 def test_checkin_update_unauth_returns_401(self, unauth_client): r = unauth_client.put("/api/checkins/1", json={"notes": "x"}) assert r.status_code == 401 def test_checkin_delete_unauth_returns_401(self, unauth_client): r = unauth_client.delete("/api/checkins/1") assert r.status_code == 401 def test_auth_me_public(self, auth_client): """Authenticated users can access /api/auth/me.""" r = auth_client.get("/api/auth/me") assert r.status_code == 200 assert r.json()["username"] == "admin" # ─── Phase E: OCR Endpoint Tests ─────────────────────────────────────────── class TestOCR: """POST /api/ocr — sticker photo OCR to extract machine_id.""" @staticmethod def _make_ocr_image(text: str) -> bytes: """Generate a PNG image with the given text rendered on it.""" from PIL import Image, ImageDraw, ImageFont img = Image.new("L", (400, 100), color=255) draw = ImageDraw.Draw(img) # Use default font — works across platforms try: font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 24) except OSError: font = ImageFont.load_default() draw.text((20, 35), text, fill=0, font=font) import io buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue() @staticmethod def _make_file(name: str, content: bytes, mime: str = "image/png"): import io return {"file": (name, io.BytesIO(content), mime)} # ── happy path ── def test_ocr_extracts_machine_id(self, client): """Upload an image containing '12345-678901' and verify extraction.""" img = self._make_ocr_image("Machine ID: 12345-678901") files = self._make_file("sticker.png", img) r = client.post("/api/ocr", files=files) assert r.status_code == 200 data = r.json() assert data["machine_id"] == "12345-678901" assert data["confidence"] == "high" assert "raw_text" in data def test_ocr_with_spaces_in_pattern(self, client): """Machine ID with space separator: '12345 678901'.""" img = self._make_ocr_image("ID: 12345 678901") files = self._make_file("sticker.png", img) r = client.post("/api/ocr", files=files) assert r.status_code == 200 data = r.json() assert data["machine_id"] == "12345-678901" def test_ocr_loose_match_fallback(self, client): """Image with a 5+ digit number but no XXXXX-XXXXXX pattern.""" img = self._make_ocr_image("Serial: 98765") files = self._make_file("sticker.png", img) r = client.post("/api/ocr", files=files) assert r.status_code == 200 data = r.json() assert data["confidence"] == "low" assert data["machine_id"] == "98765" def test_ocr_no_match_returns_none(self, client): """Image with no numeric pattern at all.""" img = self._make_ocr_image("No numbers here") files = self._make_file("sticker.png", img) r = client.post("/api/ocr", files=files) assert r.status_code == 200 data = r.json() assert data["confidence"] == "none" assert data["machine_id"] is None assert "detail" in data def test_ocr_accepts_jpg(self, client): """JPEG format should be accepted.""" from PIL import Image import io img = Image.new("L", (200, 50), color=255) buf = io.BytesIO() img.save(buf, format="JPEG") files = self._make_file("sticker.jpg", buf.getvalue(), "image/jpeg") r = client.post("/api/ocr", files=files) assert r.status_code == 200 def test_ocr_accepts_webp(self, client): """WebP format should be accepted.""" from PIL import Image import io img = Image.new("L", (200, 50), color=255) buf = io.BytesIO() img.save(buf, format="WEBP") files = self._make_file("sticker.webp", buf.getvalue(), "image/webp") r = client.post("/api/ocr", files=files) assert r.status_code == 200 # ── validation / error paths ── def test_ocr_rejects_txt_file(self, client): """Non-image file should be rejected.""" files = self._make_file("readme.txt", b"hello", "text/plain") r = client.post("/api/ocr", files=files) assert r.status_code == 400 def test_ocr_rejects_no_extension(self, client): """File with no extension should be rejected.""" files = self._make_file("sticker", b"\x89PNG\r\n\x1a\n\x00" * 50) r = client.post("/api/ocr", files=files) assert r.status_code == 400 def test_ocr_rejects_oversized_file(self, client): """File > 10 MB should be rejected.""" big = b"\x89PNG\r\n\x1a\n" + b"\x00" * (10 * 1024 * 1024 + 1) files = self._make_file("big.png", big) r = client.post("/api/ocr", files=files) assert r.status_code == 400 def test_ocr_rejects_missing_file(self, client): """No file attached should return 422.""" r = client.post("/api/ocr") assert r.status_code == 422 # ── auth ── def test_ocr_unauth_returns_401(self, unauth_client): """Unauthenticated OCR request should return 401.""" import io files = {"file": ("sticker.png", io.BytesIO(b"\x89PNG\r\n\x1a\n\x00" * 200), "image/png")} r = unauth_client.post("/api/ocr", files=files) assert r.status_code == 401 # ─── Phase E: Role-Based Access Tests ────────────────────────────────────── class TestRoleBasedAccess: """Verify role system — user roles, auth/me, and cross-role access.""" @staticmethod def _login_as(client, username: str, password: str = "testpass123"): """Create a user (if needed), login, and return _AuthTestClient wrapper + role.""" # Try to create the user (may already exist from seed) import importlib, sys r = client.post("/api/users", json={ "username": username, "password": password, "role": "technician", }) # Login r = client.post("/api/auth/login", json={"username": username, "password": password}) if r.status_code != 200: # User might exist with a different password — try admin default r = client.post("/api/auth/login", json={"username": username, "password": "changeme"}) token = r.json()["token"] auth_headers = {"Authorization": f"Bearer {token}"} # Get role from /api/auth/me r2 = client.get("/api/auth/me", headers=auth_headers) role = r2.json()["role"] wrapper = _AuthTestClient(client, auth_headers) return wrapper, role # ── role validation ── def test_create_user_with_valid_roles(self, client): """Users can be created with admin, technician, or readonly role.""" for role in ("admin", "technician", "readonly"): r = client.post("/api/users", json={ "username": f"roletest_{role}", "password": "test123", "role": role, }) assert r.status_code == 201, f"Failed for role={role}: {r.json()}" assert r.json()["role"] == role def test_create_user_invalid_role_rejected(self, client): """Invalid roles should be rejected.""" r = client.post("/api/users", json={ "username": "badrole", "password": "test123", "role": "superadmin", }) assert r.status_code == 422 assert "Invalid role" in r.json()["detail"] def test_create_user_defaults_to_technician(self, client): """Users without explicit role default to technician.""" r = client.post("/api/users", json={ "username": "defaultrole", "password": "test123", }) assert r.status_code == 201 assert r.json()["role"] == "technician" def test_update_user_role(self, client): """Updating a user's role works.""" r = client.post("/api/users", json={ "username": "roleupdateme", "password": "test123", "role": "technician", }) uid = r.json()["id"] r = client.put(f"/api/users/{uid}", json={"role": "admin"}) assert r.status_code == 200 assert r.json()["role"] == "admin" def test_update_user_invalid_role_rejected(self, client): """Updating to an invalid role should be rejected.""" r = client.post("/api/users", json={ "username": "badupdaterole", "password": "test123", }) uid = r.json()["id"] r = client.put(f"/api/users/{uid}", json={"role": "bogus"}) assert r.status_code == 422 assert "Invalid role" in r.json()["detail"] # ── auth/me returns correct role ── def test_auth_me_returns_admin_role(self, client): """auth/me returns role='admin' for admin users.""" r = client.post("/api/users", json={ "username": "authme_admin", "password": "test123", "role": "admin", }) r = client.post("/api/auth/login", json={"username": "authme_admin", "password": "test123"}) token = r.json()["token"] r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 assert r.json()["role"] == "admin" assert r.json()["username"] == "authme_admin" def test_auth_me_returns_technician_role(self, client): """auth/me returns role='technician' for technician users.""" r = client.post("/api/users", json={ "username": "authme_tech", "password": "test123", "role": "technician", }) r = client.post("/api/auth/login", json={"username": "authme_tech", "password": "test123"}) token = r.json()["token"] r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 assert r.json()["role"] == "technician" def test_auth_me_returns_readonly_role(self, client): """auth/me returns role='readonly' for readonly users.""" r = client.post("/api/users", json={ "username": "authme_ro", "password": "test123", "role": "readonly", }) r = client.post("/api/auth/login", json={"username": "authme_ro", "password": "test123"}) token = r.json()["token"] r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 assert r.json()["role"] == "readonly" # ── all roles can access protected endpoints ── def test_readonly_user_can_access_assets(self, client): """Readonly users can access protected GET endpoints.""" r = client.post("/api/users", json={ "username": "ro_access", "password": "test123", "role": "readonly", }) r = client.post("/api/auth/login", json={"username": "ro_access", "password": "test123"}) token = r.json()["token"] r = client.get("/api/assets", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 def test_technician_user_can_access_assets(self, client): """Technician users can access protected endpoints.""" r = client.post("/api/users", json={ "username": "tech_access", "password": "test123", "role": "technician", }) r = client.post("/api/auth/login", json={"username": "tech_access", "password": "test123"}) token = r.json()["token"] r = client.get("/api/assets", headers={"Authorization": f"Bearer {token}"}) assert r.status_code == 200 # ── role stored correctly in user list ── def test_list_users_includes_role(self, client): """User listing includes the role field.""" r = client.post("/api/users", json={ "username": "listrole", "password": "test123", "role": "admin", }) r = client.get("/api/users") users = r.json() list_user = [u for u in users if u["username"] == "listrole"] assert len(list_user) == 1 assert list_user[0]["role"] == "admin" # ── password_hash never leaked ── def test_auth_me_never_leaks_password_hash(self, client): """auth/me must not expose password_hash regardless of role.""" for role in ("admin", "technician", "readonly"): username = f"noleak_{role}" r = client.post("/api/users", json={ "username": username, "password": "test123", "role": role, }) r = client.post("/api/auth/login", json={"username": username, "password": "test123"}) token = r.json()["token"] r = client.get("/api/auth/me", headers={"Authorization": f"Bearer {token}"}) assert "password_hash" not in r.json(), f"password_hash leaked for role={role}" def test_list_users_never_leaks_password_hash(self, client): """User listing must not expose password_hash.""" r = client.get("/api/users") for user in r.json(): assert "password_hash" not in user, f"password_hash leaked for {user.get('username')}" # ─── Geolocation: lat/lng columns and geofence_radius_meters ────────────── def test_create_asset_with_latlng(client): resp = client.post("/api/assets", json={ "machine_id": "LATLNG-000001", "name": "Geo Fridge", "category": "Appliances", "latitude": 40.7128, "longitude": -74.0060, "geofence_radius_meters": 150, }) assert resp.status_code == 201 data = resp.json() assert data["latitude"] == 40.7128 assert data["longitude"] == -74.0060 assert data["geofence_radius_meters"] == 150 def test_update_asset_latlng(client): # Create first resp = client.post("/api/assets", json={ "machine_id": "LATLNG-000002", "name": "Updatable Geo", "category": "Equipment", }) assert resp.status_code == 201 asset_id = resp.json()["id"] # Update resp = client.put(f"/api/assets/{asset_id}", json={ "latitude": 34.0522, "longitude": -118.2437, "geofence_radius_meters": 200, }) assert resp.status_code == 200 data = resp.json() assert data["latitude"] == 34.0522 assert data["longitude"] == -118.2437 assert data["geofence_radius_meters"] == 200 def test_create_location_with_latlng(client): # Need a customer first resp = client.post("/api/customers", json={"name": "Geo Customer"}) assert resp.status_code == 201 cust_id = resp.json()["id"] resp = client.post("/api/locations", json={ "customer_id": cust_id, "name": "Geo Location", "latitude": 51.5074, "longitude": -0.1278, }) assert resp.status_code == 201 data = resp.json() assert data["latitude"] == 51.5074 assert data["longitude"] == -0.1278 def test_update_location_latlng(client): resp = client.post("/api/customers", json={"name": "Geo Customer 2"}) cust_id = resp.json()["id"] resp = client.post("/api/locations", json={ "customer_id": cust_id, "name": "Updatable Location", }) loc_id = resp.json()["id"] resp = client.put(f"/api/locations/{loc_id}", json={ "latitude": 48.8566, "longitude": 2.3522, }) assert resp.status_code == 200 data = resp.json() assert data["latitude"] == 48.8566 assert data["longitude"] == 2.3522 def test_asset_latlng_defaults(client): resp = client.post("/api/assets", json={ "machine_id": "LATLNG-000005", "name": "No Coords Asset", "category": "Other", }) assert resp.status_code == 201 data = resp.json() assert data["latitude"] is None assert data["longitude"] is None assert data["geofence_radius_meters"] == 50 # default def test_location_latlng_defaults(client): resp = client.post("/api/customers", json={"name": "Default Cust"}) cust_id = resp.json()["id"] resp = client.post("/api/locations", json={ "customer_id": cust_id, "name": "Default Location", }) assert resp.status_code == 201 data = resp.json() assert data["latitude"] is None assert data["longitude"] is None def test_get_asset_returns_latlng(client): resp = client.post("/api/assets", json={ "machine_id": "LATLNG-GET-001", "name": "GET Test Geo", "category": "Furniture", "latitude": -33.8688, "longitude": 151.2093, "geofence_radius_meters": 75, }) asset_id = resp.json()["id"] resp = client.get(f"/api/assets/{asset_id}") assert resp.status_code == 200 data = resp.json() assert data["latitude"] == -33.8688 assert data["longitude"] == 151.2093 assert data["geofence_radius_meters"] == 75 # ─── Phase 0.2: Proximity API Tests ────────────────────────────────────────── def test_proximity_returns_nearby_assets(client): """Assets with coordinates within radius should be returned, sorted by distance.""" # Seed asset near NYC resp = client.post("/api/assets", json={ "machine_id": "PROX-000001", "name": "Nearby Fridge", "category": "Appliances", "latitude": 40.7128, "longitude": -74.0060, "geofence_radius_meters": 100, }) assert resp.status_code == 201 # Seed asset ~10km away resp = client.post("/api/assets", json={ "machine_id": "PROX-000002", "name": "Far Freezer", "category": "Appliances", "latitude": 40.8000, "longitude": -74.1000, "geofence_radius_meters": 100, }) assert resp.status_code == 201 # Query from near the first asset resp = client.get("/api/proximity?lat=40.7129&lng=-74.0061&radius_meters=200") assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 assert data[0]["machine_id"] == "PROX-000001" # Closest first assert "distance_meters" in data[0] assert data[0]["distance_meters"] < 200 def test_proximity_no_nearby_assets(client): """No assets within radius returns empty list.""" resp = client.post("/api/assets", json={ "machine_id": "PROX-EMPTY-001", "name": "Lonely Asset", "category": "Equipment", "latitude": 40.7128, "longitude": -74.0060, }) assert resp.status_code == 201 # Query from far away (London) resp = client.get("/api/proximity?lat=51.5074&lng=-0.1278&radius_meters=100") assert resp.status_code == 200 data = resp.json() assert data == [] def test_proximity_missing_params(client): """Missing lat/lng returns 422.""" resp = client.get("/api/proximity?lat=40.7") assert resp.status_code == 422 resp = client.get("/api/proximity?lng=-74.0") assert resp.status_code == 422 def test_proximity_invalid_radius(client): """radius_meters below 1 or above 50000 returns 422.""" resp = client.get("/api/proximity?lat=40.7&lng=-74.0&radius_meters=0") assert resp.status_code == 422 resp = client.get("/api/proximity?lat=40.7&lng=-74.0&radius_meters=50001") assert resp.status_code == 422 def test_proximity_excludes_null_coordinates(client): """Assets without lat/lng should be excluded from proximity results.""" resp = client.post("/api/assets", json={ "machine_id": "PROX-NULL-001", "name": "No Coords Asset", "category": "Other", }) assert resp.status_code == 201 resp = client.get("/api/proximity?lat=40.7128&lng=-74.0060&radius_meters=50000") assert resp.status_code == 200 data = resp.json() machine_ids = [a["machine_id"] for a in data] assert "PROX-NULL-001" not in machine_ids # ─── Phase 0.3: Geofence Point-Check Tests ─────────────────────────────────── def _create_geofence(client, name, points, color="#3388ff"): resp = client.post("/api/geofences", json={ "name": name, "points": points, "color": color, }) assert resp.status_code == 201 return resp.json()["id"] def test_geofence_point_check_inside(client): """Point inside polygon returns the geofence.""" # Square around NYC points = [ {"lat": 40.70, "lng": -74.02}, {"lat": 40.70, "lng": -73.98}, {"lat": 40.74, "lng": -73.98}, {"lat": 40.74, "lng": -74.02}, ] _create_geofence(client, "NYC Zone", points) # Point inside the square resp = client.post("/api/geofences/check", json={ "lat": 40.72, "lng": -74.00, }) assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 assert data[0]["name"] == "NYC Zone" def test_geofence_point_check_outside(client): """Point outside polygon returns empty.""" points = [ {"lat": 40.70, "lng": -74.02}, {"lat": 40.70, "lng": -73.98}, {"lat": 40.74, "lng": -73.98}, {"lat": 40.74, "lng": -74.02}, ] _create_geofence(client, "NYC Zone", points) # Point far outside (LA) resp = client.post("/api/geofences/check", json={ "lat": 34.05, "lng": -118.24, }) assert resp.status_code == 200 data = resp.json() assert data == [] def test_geofence_point_check_empty(client): """No geofences defined returns empty list.""" resp = client.post("/api/geofences/check", json={ "lat": 40.72, "lng": -74.00, }) assert resp.status_code == 200 data = resp.json() assert data == [] def test_geofence_point_check_multiple_matches(client): """Point inside overlapping geofences returns all matches.""" points1 = [ {"lat": 40.70, "lng": -74.02}, {"lat": 40.70, "lng": -73.98}, {"lat": 40.74, "lng": -73.98}, {"lat": 40.74, "lng": -74.02}, ] points2 = [ {"lat": 40.71, "lng": -74.01}, {"lat": 40.71, "lng": -73.99}, {"lat": 40.73, "lng": -73.99}, {"lat": 40.73, "lng": -74.01}, ] _create_geofence(client, "Outer Zone", points1) _create_geofence(client, "Inner Zone", points2) resp = client.post("/api/geofences/check", json={ "lat": 40.72, "lng": -74.00, }) assert resp.status_code == 200 data = resp.json() assert len(data) == 2 names = {g["name"] for g in data} assert names == {"Outer Zone", "Inner Zone"} # ─── Helpers for upload / OCR tests ─────────────────────────────────────── import io import struct import zlib def _minimal_png_bytes() -> bytes: """Return bytes of a minimal 1×1 white PNG (valid, ~68 bytes).""" def _chunk(ctype: bytes, data: bytes) -> bytes: c = ctype + data return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0) idat = zlib.compress(b"\x00" + b"\xff\xff\xff") # filter-byte + white pixel return b"\x89PNG\r\n\x1a\n" + _chunk(b"IHDR", ihdr) + _chunk(b"IDAT", idat) + _chunk(b"IEND", b"") PNG_BYTES = _minimal_png_bytes() def _make_jpeg_bytes() -> bytes: """Return bytes of a minimal JPEG (valid, ~631 bytes).""" from PIL import Image as PILImage buf = io.BytesIO() img = PILImage.new("RGB", (1, 1), color=(255, 255, 255)) img.save(buf, format="JPEG") return buf.getvalue() def _oversized_bytes(mb: int) -> bytes: """Return a byte-string larger than *mb* megabytes.""" return b"x" * (mb * 1024 * 1024 + 1) # ─── Phase F: File Uploads ──────────────────────────────────────────────── class TestIconUpload: """POST /api/upload/icon — icon file upload endpoint.""" def test_upload_png_icon_returns_201(self, client): r = client.post( "/api/upload/icon", files={"file": ("icon.png", io.BytesIO(PNG_BYTES), "image/png")}, ) assert r.status_code == 201 data = r.json() assert "path" in data assert data["path"].startswith("/uploads/icons/") assert data["path"].endswith(".png") def test_upload_jpg_icon_returns_201(self, client): jpg_bytes = _make_jpeg_bytes() r = client.post( "/api/upload/icon", files={"file": ("icon.jpg", io.BytesIO(jpg_bytes), "image/jpeg")}, ) assert r.status_code == 201 assert r.json()["path"].startswith("/uploads/icons/") def test_upload_svg_icon_returns_201(self, client): svg_bytes = b'' r = client.post( "/api/upload/icon", files={"file": ("icon.svg", io.BytesIO(svg_bytes), "image/svg+xml")}, ) assert r.status_code == 201 assert r.json()["path"].endswith(".svg") def test_upload_icon_rejects_gif_extension(self, client): r = client.post( "/api/upload/icon", files={"file": ("icon.gif", io.BytesIO(PNG_BYTES), "image/gif")}, ) assert r.status_code == 400 assert "Invalid file type" in r.json()["detail"] def test_upload_icon_rejects_no_extension(self, client): r = client.post( "/api/upload/icon", files={"file": ("icon", io.BytesIO(PNG_BYTES), "application/octet-stream")}, ) assert r.status_code == 400 def test_upload_icon_rejects_oversized_file(self, client): """ICON_MAX_SIZE = 2 MB; send >2 MB.""" big = _oversized_bytes(3) r = client.post( "/api/upload/icon", files={"file": ("big.png", io.BytesIO(big), "image/png")}, ) assert r.status_code == 413 assert "too large" in r.json()["detail"].lower() def test_upload_icon_file_saved_to_disk(self, client): """Uploaded file must exist on disk under uploads/icons/.""" r = client.post( "/api/upload/icon", files={"file": ("disk.png", io.BytesIO(PNG_BYTES), "image/png")}, ) assert r.status_code == 201 rel_path = r.json()["path"] # Strip leading / to resolve from project root from pathlib import Path abs_path = Path(__file__).parent.parent / rel_path.lstrip("/") assert abs_path.exists(), f"Expected file at {abs_path}" assert abs_path.read_bytes() == PNG_BYTES class TestPhotoUpload: """POST /api/upload/photo — photo file upload endpoint.""" def test_upload_png_photo_returns_201(self, client): r = client.post( "/api/upload/photo", files={"file": ("photo.png", io.BytesIO(PNG_BYTES), "image/png")}, ) assert r.status_code == 201 data = r.json() assert "path" in data assert data["path"].startswith("/uploads/photos/") def test_upload_jpg_photo_returns_201(self, client): jpg_bytes = _make_jpeg_bytes() r = client.post( "/api/upload/photo", files={"file": ("photo.jpg", io.BytesIO(jpg_bytes), "image/jpeg")}, ) assert r.status_code == 201 assert r.json()["path"].startswith("/uploads/photos/") def test_upload_photo_rejects_svg_extension(self, client): """SVG is not allowed for photos (photo is raster-only).""" svg_bytes = b"" r = client.post( "/api/upload/photo", files={"file": ("photo.svg", io.BytesIO(svg_bytes), "image/svg+xml")}, ) assert r.status_code == 400 assert "Invalid file type" in r.json()["detail"] def test_upload_photo_rejects_gif_extension(self, client): r = client.post( "/api/upload/photo", files={"file": ("photo.gif", io.BytesIO(PNG_BYTES), "image/gif")}, ) assert r.status_code == 400 def test_upload_photo_rejects_no_extension(self, client): r = client.post( "/api/upload/photo", files={"file": ("photo", io.BytesIO(PNG_BYTES), "application/octet-stream")}, ) assert r.status_code == 400 def test_upload_photo_rejects_oversized_file(self, client): """PHOTO_MAX_SIZE = 10 MB; send >10 MB.""" big = _oversized_bytes(12) r = client.post( "/api/upload/photo", files={"file": ("big.jpg", io.BytesIO(big), "image/jpeg")}, ) assert r.status_code == 413 assert "too large" in r.json()["detail"].lower() def test_upload_photo_file_saved_to_disk(self, client): jpg_bytes = _make_jpeg_bytes() r = client.post( "/api/upload/photo", files={"file": ("disk.jpg", io.BytesIO(jpg_bytes), "image/jpeg")}, ) assert r.status_code == 201 rel_path = r.json()["path"] from pathlib import Path abs_path = Path(__file__).parent.parent / rel_path.lstrip("/") assert abs_path.exists(), f"Expected file at {abs_path}" assert abs_path.read_bytes() == jpg_bytes # ─── Phase F: OCR Endpoint ──────────────────────────────────────────────── class TestOCR: """POST /api/ocr — sticker OCR endpoint.""" def _make_ocr_image(self, text: str) -> io.BytesIO: """Create a PNG image with *text* drawn on it, readable by Tesseract.""" from PIL import Image as PILImage, ImageDraw, ImageFont img = PILImage.new("L", (400, 100), color=255) # white background, grayscale draw = ImageDraw.Draw(img) # Use default font — works across platforms draw.text((10, 30), text, fill=0) buf = io.BytesIO() img.save(buf, format="PNG") buf.seek(0) return buf def test_ocr_extracts_machine_id_pattern(self, client): """Image containing '12345-678901' should return high-confidence match.""" buf = self._make_ocr_image("Machine ID: 12345-678901") r = client.post( "/api/ocr", files={"file": ("sticker.png", buf, "image/png")}, ) assert r.status_code == 200 data = r.json() assert data["machine_id"] == "12345-678901" assert data["confidence"] == "high" assert "raw_text" in data def test_ocr_loose_match_fallback(self, client): """Image with 5+ digits but no hyphen pattern gets low confidence.""" buf = self._make_ocr_image("Serial: 12345678") r = client.post( "/api/ocr", files={"file": ("sticker.png", buf, "image/png")}, ) assert r.status_code == 200 data = r.json() assert data["machine_id"] == "12345678" assert data["confidence"] == "low" def test_ocr_no_match_returns_none(self, client): """Image with no digit patterns returns confidence=none.""" buf = self._make_ocr_image("Hello World!") r = client.post( "/api/ocr", files={"file": ("sticker.png", buf, "image/png")}, ) assert r.status_code == 200 data = r.json() assert data["machine_id"] is None assert data["confidence"] == "none" assert "detail" in data def test_ocr_with_hyphen_but_no_digits(self, client): """Image with hyphen but no digit pattern returns no match.""" buf = self._make_ocr_image("ABC-DEFGHI") r = client.post( "/api/ocr", files={"file": ("sticker.png", buf, "image/png")}, ) assert r.status_code == 200 data = r.json() # No digits -> no match assert data["confidence"] == "none" def test_ocr_rejects_invalid_extension(self, client): """Non-image extensions like .txt are rejected with 400.""" r = client.post( "/api/ocr", files={"file": ("doc.txt", io.BytesIO(b"not an image"), "text/plain")}, ) assert r.status_code == 400 assert "Unsupported image format" in r.json()["detail"] def test_ocr_rejects_no_extension(self, client): r = client.post( "/api/ocr", files={"file": ("sticker", io.BytesIO(PNG_BYTES), "application/octet-stream")}, ) assert r.status_code == 400 assert "Unsupported image format" in r.json()["detail"] def test_ocr_rejects_oversized_file(self, client): """OCR max = 10 MB; send >10 MB.""" big = _oversized_bytes(12) r = client.post( "/api/ocr", files={"file": ("big.png", io.BytesIO(big), "image/png")}, ) assert r.status_code == 400 assert "too large" in r.json()["detail"].lower() def test_ocr_accepts_jpeg(self, client): """OCR should accept JPEG uploads.""" jpg_bytes = _make_jpeg_bytes() r = client.post( "/api/ocr", files={"file": ("sticker.jpg", io.BytesIO(jpg_bytes), "image/jpeg")}, ) # It might fail OCR (no text in a blank JPEG) but should not be rejected on format assert r.status_code == 200 data = r.json() assert data["confidence"] == "none" def test_ocr_handles_corrupt_image(self, client): """A corrupt/malformed image file should return 500.""" r = client.post( "/api/ocr", files={"file": ("bad.png", io.BytesIO(b"this is not a PNG"), "image/png")}, ) assert r.status_code == 500 assert "OCR processing failed" in r.json()["detail"]