3674 lines
140 KiB
Python
3674 lines
140 KiB
Python
"""
|
||
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'<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>'
|
||
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'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40"/></svg>'
|
||
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"<svg></svg>"
|
||
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"]
|