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