753 lines
31 KiB
Python
753 lines
31 KiB
Python
"""
|
|
Map API tests — geofence CRUD, proximity, asset coordinate persistence.
|
|
Comprehensive coverage: happy paths, edge cases, error handling.
|
|
"""
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
TEST_DB = Path(__file__).parent / "test_map_api.db"
|
|
os.environ["CANTEEN_DB_PATH"] = str(TEST_DB)
|
|
os.environ["CANTEEN_SKIP_AUTH"] = "1"
|
|
|
|
|
|
def _clean_db():
|
|
for suffix in ("", "-shm", "-wal", "-journal"):
|
|
p = TEST_DB.with_suffix(TEST_DB.suffix + suffix)
|
|
if p.exists():
|
|
p.unlink()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_db():
|
|
_clean_db()
|
|
yield
|
|
_clean_db()
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
_clean_db()
|
|
for mod in list(sys.modules.keys()):
|
|
if mod == "server" or mod.startswith("server."):
|
|
del sys.modules[mod]
|
|
import server
|
|
import importlib
|
|
importlib.invalidate_caches()
|
|
with TestClient(server.app) as tc:
|
|
yield tc
|
|
|
|
|
|
# ─── helpers ────────────────────────────────────────────────────────────────
|
|
|
|
def _square_points(lat=0, lng=0, size=1):
|
|
"""Return a square polygon centered at (lat, lng)."""
|
|
return [
|
|
{"lat": lat - size, "lng": lng - size},
|
|
{"lat": lat - size, "lng": lng + size},
|
|
{"lat": lat + size, "lng": lng + size},
|
|
{"lat": lat + size, "lng": lng - size},
|
|
]
|
|
|
|
|
|
def _create_geofence(client, name, lat=0, lng=0, size=1, color="#3388ff"):
|
|
return client.post("/api/geofences", json={
|
|
"name": name,
|
|
"points": _square_points(lat, lng, size),
|
|
"color": color,
|
|
})
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 1. Geofence CRUD — happy paths
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestGeofenceCRUD:
|
|
"""Full geofence lifecycle: create, list, get, update, delete."""
|
|
|
|
def test_create_geofence(self, client):
|
|
"""Create a geofence with polygon points."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "Test Zone",
|
|
"points": [{"lat": 40.0, "lng": -74.0}, {"lat": 40.1, "lng": -74.0},
|
|
{"lat": 40.1, "lng": -73.9}, {"lat": 40.0, "lng": -73.9}],
|
|
"color": "#ff0000",
|
|
})
|
|
assert r.status_code == 201
|
|
data = r.json()
|
|
assert data["name"] == "Test Zone"
|
|
assert data["color"] == "#ff0000"
|
|
assert "points" in data
|
|
assert "id" in data
|
|
|
|
def test_create_geofence_default_color(self, client):
|
|
"""Create without color — uses default #3388ff."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "Default Color",
|
|
"points": _square_points(),
|
|
})
|
|
assert r.status_code == 201
|
|
data = r.json()
|
|
assert data["color"] == "#3388ff"
|
|
|
|
def test_list_geofences(self, client):
|
|
"""List all geofences."""
|
|
_create_geofence(client, "A")
|
|
r = client.get("/api/geofences")
|
|
assert r.status_code == 200
|
|
assert len(r.json()) == 1
|
|
|
|
def test_list_geofences_empty(self, client):
|
|
"""No geofences — empty list."""
|
|
r = client.get("/api/geofences")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_list_geofences_sorted_by_name(self, client):
|
|
"""Geofences are returned sorted by name."""
|
|
_create_geofence(client, "Zulu")
|
|
_create_geofence(client, "Alpha")
|
|
_create_geofence(client, "Mike")
|
|
r = client.get("/api/geofences")
|
|
names = [g["name"] for g in r.json()]
|
|
assert names == sorted(names)
|
|
|
|
def test_update_geofence_name_and_color(self, client):
|
|
"""Update geofence name and color."""
|
|
gf = _create_geofence(client, "Old Name", color="#ff0000").json()
|
|
gid = gf["id"]
|
|
|
|
r = client.put(f"/api/geofences/{gid}", json={
|
|
"name": "New Name", "color": "#00ff00",
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "New Name"
|
|
assert r.json()["color"] == "#00ff00"
|
|
|
|
def test_update_geofence_points(self, client):
|
|
"""Update geofence polygon points."""
|
|
gf = _create_geofence(client, "Original Points", size=1).json()
|
|
gid = gf["id"]
|
|
|
|
new_points = _square_points(lat=10, lng=20, size=2)
|
|
r = client.put(f"/api/geofences/{gid}", json={"points": new_points})
|
|
assert r.status_code == 200
|
|
returned = r.json()["points"]
|
|
if isinstance(returned, str):
|
|
import json
|
|
returned = json.loads(returned)
|
|
assert len(returned) == 4
|
|
|
|
def test_update_geofence_partial(self, client):
|
|
"""Partial update — only change name, color unchanged."""
|
|
gf = _create_geofence(client, "Old", color="#abc123").json()
|
|
gid = gf["id"]
|
|
|
|
r = client.put(f"/api/geofences/{gid}", json={"name": "New"})
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "New"
|
|
assert r.json()["color"] == "#abc123"
|
|
|
|
def test_delete_geofence(self, client):
|
|
"""Delete a geofence."""
|
|
gf = _create_geofence(client, "Delete Me").json()
|
|
r = client.delete(f"/api/geofences/{gf['id']}")
|
|
assert r.status_code == 204
|
|
# Verify removed
|
|
r = client.get("/api/geofences")
|
|
assert len(r.json()) == 0
|
|
|
|
def test_geofence_points_persist(self, client):
|
|
"""Points are stored and returned correctly."""
|
|
points = [{"lat": 10.0, "lng": 20.0}, {"lat": 10.5, "lng": 20.5},
|
|
{"lat": 11.0, "lng": 20.0}]
|
|
gf = client.post("/api/geofences", json={
|
|
"name": "Triangle", "points": points, "color": "#0000ff"
|
|
}).json()
|
|
returned = gf["points"]
|
|
if isinstance(returned, str):
|
|
import json
|
|
returned = json.loads(returned)
|
|
assert len(returned) == 3
|
|
assert returned[0]["lat"] == 10.0
|
|
|
|
def test_duplicate_name_allowed(self, client):
|
|
"""Server allows duplicate geofence names (no uniqueness constraint)."""
|
|
_create_geofence(client, "Same Name")
|
|
r = _create_geofence(client, "Same Name")
|
|
assert r.status_code == 201
|
|
# Both should appear in list
|
|
lst = client.get("/api/geofences").json()
|
|
names = [g["name"] for g in lst]
|
|
assert names.count("Same Name") == 2
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 2. Geofence CRUD — error handling
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestGeofenceErrors:
|
|
"""Edge cases: 404s, 422s, invalid inputs."""
|
|
|
|
def test_create_missing_name(self, client):
|
|
"""Create without required 'name' field → 422."""
|
|
r = client.post("/api/geofences", json={
|
|
"points": _square_points(),
|
|
})
|
|
assert r.status_code == 422
|
|
|
|
def test_create_missing_points(self, client):
|
|
"""Create without required 'points' field → 422."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "No Points",
|
|
})
|
|
assert r.status_code == 422
|
|
|
|
def test_create_empty_name(self, client):
|
|
"""Create with empty name string — should still work (no server-side validation)."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "",
|
|
"points": _square_points(),
|
|
})
|
|
# Server doesn't validate empty name — it just stores it
|
|
assert r.status_code == 201
|
|
|
|
def test_create_invalid_points_type(self, client):
|
|
"""Create with points as string instead of list → 422."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "Bad Points",
|
|
"points": "not-a-list",
|
|
})
|
|
assert r.status_code == 422
|
|
|
|
def test_update_nonexistent_geofence(self, client):
|
|
"""Update a geofence that doesn't exist → 404."""
|
|
r = client.put("/api/geofences/99999", json={"name": "Ghost"})
|
|
assert r.status_code == 404
|
|
|
|
def test_delete_nonexistent_geofence(self, client):
|
|
"""Delete a geofence that doesn't exist → 404."""
|
|
r = client.delete("/api/geofences/99999")
|
|
assert r.status_code == 404
|
|
|
|
def test_delete_then_verify_gone(self, client):
|
|
"""After delete, ensure geofence cannot be updated."""
|
|
gf = _create_geofence(client, "Ephemeral").json()
|
|
client.delete(f"/api/geofences/{gf['id']}")
|
|
r = client.put(f"/api/geofences/{gf['id']}", json={"name": "Revived?"})
|
|
assert r.status_code == 404
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 3. Geofence point-in-polygon check
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestGeofenceCheck:
|
|
"""POST /api/geofences/check — point-in-polygon."""
|
|
|
|
def test_point_inside_single_geofence(self, client):
|
|
"""Returns the geofence containing the point."""
|
|
_create_geofence(client, "Central", lat=40, lng=-74, size=1)
|
|
r = client.post("/api/geofences/check", json={"lat": 40, "lng": -74})
|
|
assert r.status_code == 200
|
|
names = [g["name"] for g in r.json()]
|
|
assert "Central" in names
|
|
|
|
def test_point_outside_all_geofences(self, client):
|
|
"""Returns empty list when point is outside all geofences."""
|
|
_create_geofence(client, "Local", lat=0, lng=0, size=1)
|
|
r = client.post("/api/geofences/check", json={"lat": 50, "lng": 50})
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_point_inside_multiple_geofences(self, client):
|
|
"""Returns all geofences containing the point."""
|
|
_create_geofence(client, "Big", lat=0, lng=0, size=10)
|
|
_create_geofence(client, "Small", lat=0, lng=0, size=1)
|
|
r = client.post("/api/geofences/check", json={"lat": 0, "lng": 0})
|
|
assert r.status_code == 200
|
|
assert len(r.json()) == 2
|
|
|
|
def test_check_no_geofences(self, client):
|
|
"""No geofences exist — empty array."""
|
|
r = client.post("/api/geofences/check", json={"lat": 0, "lng": 0})
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_check_missing_lat(self, client):
|
|
"""Missing 'lat' field → 422."""
|
|
r = client.post("/api/geofences/check", json={"lng": 0})
|
|
assert r.status_code == 422
|
|
|
|
def test_check_missing_lng(self, client):
|
|
"""Missing 'lng' field → 422."""
|
|
r = client.post("/api/geofences/check", json={"lat": 0})
|
|
assert r.status_code == 422
|
|
|
|
def test_check_empty_body(self, client):
|
|
"""Empty request body → 422."""
|
|
r = client.post("/api/geofences/check", json={})
|
|
assert r.status_code == 422
|
|
|
|
def test_point_on_polygon_boundary(self, client):
|
|
"""Point exactly on the edge of a polygon — ray-casting may or may not include."""
|
|
_create_geofence(client, "Square", lat=0, lng=0, size=1)
|
|
# Test a point on one of the edges
|
|
r = client.post("/api/geofences/check", json={"lat": -1.0, "lng": 0})
|
|
assert r.status_code == 200
|
|
# Boundary behavior is implementation-defined; just verify no crash
|
|
assert isinstance(r.json(), list)
|
|
|
|
def test_self_intersecting_polygon(self, client):
|
|
"""Self-intersecting bow-tie polygon — should not crash."""
|
|
# Bow-tie shape: (0,0) → (1,1) → (0,1) → (1,0)
|
|
r = client.post("/api/geofences", json={
|
|
"name": "Bowtie",
|
|
"points": [
|
|
{"lat": 0, "lng": 0},
|
|
{"lat": 1, "lng": 1},
|
|
{"lat": 0, "lng": 1},
|
|
{"lat": 1, "lng": 0},
|
|
],
|
|
"color": "#ff0000",
|
|
})
|
|
assert r.status_code == 201
|
|
# Point-in-polygon check should not crash
|
|
r2 = client.post("/api/geofences/check", json={"lat": 0.5, "lng": 0.5})
|
|
assert r2.status_code == 200
|
|
assert isinstance(r2.json(), list)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 4. Proximity search
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestProximitySearch:
|
|
"""GET /api/proximity — assets near a GPS point."""
|
|
|
|
def test_proximity_within_radius(self, client):
|
|
"""Asset inside radius appears in results."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-A",
|
|
"name": "Nearby",
|
|
"latitude": 40.7128, "longitude": -74.006,
|
|
})
|
|
# Query ~100m away, radius 500m → should be included
|
|
r = client.get("/api/proximity?lat=40.713&lng=-74.007&radius_meters=500")
|
|
assert r.status_code == 200
|
|
mids = [a["machine_id"] for a in r.json()]
|
|
assert "PROX-A" in mids
|
|
|
|
def test_proximity_outside_radius(self, client):
|
|
"""Asset far from query point excluded."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-B",
|
|
"name": "Far",
|
|
"latitude": 40.7128, "longitude": -74.006,
|
|
})
|
|
# NYC vs Tokyo — half the planet apart
|
|
r = client.get("/api/proximity?lat=35.676&lng=139.650&radius_meters=1000")
|
|
assert r.status_code == 200
|
|
mids = [a["machine_id"] for a in r.json()]
|
|
assert "PROX-B" not in mids
|
|
|
|
def test_proximity_no_coords(self, client):
|
|
"""Asset without lat/lng excluded."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-NOCOORD",
|
|
"name": "No GPS",
|
|
})
|
|
r = client.get("/api/proximity?lat=0&lng=0&radius_meters=50000")
|
|
assert r.status_code == 200
|
|
mids = [a["machine_id"] for a in r.json()]
|
|
assert "PROX-NOCOORD" not in mids
|
|
|
|
def test_proximity_empty_db(self, client):
|
|
"""No assets — empty results."""
|
|
r = client.get("/api/proximity?lat=0&lng=0&radius_meters=1000")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_proximity_default_radius(self, client):
|
|
"""Default radius is 200m when not specified."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-DEF",
|
|
"name": "Default Radius",
|
|
"latitude": 40.7128, "longitude": -74.006,
|
|
})
|
|
# ~20m away — well within default 200m
|
|
r = client.get("/api/proximity?lat=40.7129&lng=-74.0061")
|
|
assert r.status_code == 200
|
|
mids = [a["machine_id"] for a in r.json()]
|
|
assert "PROX-DEF" in mids
|
|
|
|
def test_proximity_missing_lat(self, client):
|
|
"""Missing required 'lat' param → 422."""
|
|
r = client.get("/api/proximity?lng=0")
|
|
assert r.status_code == 422
|
|
|
|
def test_proximity_missing_lng(self, client):
|
|
"""Missing required 'lng' param → 422."""
|
|
r = client.get("/api/proximity?lat=0")
|
|
assert r.status_code == 422
|
|
|
|
def test_proximity_no_params(self, client):
|
|
"""No query params → 422."""
|
|
r = client.get("/api/proximity")
|
|
assert r.status_code == 422
|
|
|
|
def test_proximity_custom_radius(self, client):
|
|
"""Custom radius_meters value respected."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-RAD",
|
|
"name": "Radius Test",
|
|
"latitude": 40.7128, "longitude": -74.006,
|
|
})
|
|
# ~1.5km away, radius 500m → should NOT be included
|
|
r = client.get("/api/proximity?lat=40.725&lng=-74.006&radius_meters=500")
|
|
assert r.status_code == 200
|
|
mids = [a["machine_id"] for a in r.json()]
|
|
assert "PROX-RAD" not in mids
|
|
|
|
def test_proximity_max_radius(self, client):
|
|
"""Maximum radius of 50000m (~50km) should work."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-MAX",
|
|
"name": "Max Radius",
|
|
"latitude": 40.8, "longitude": -74.0,
|
|
})
|
|
r = client.get("/api/proximity?lat=40.7128&lng=-74.006&radius_meters=50000")
|
|
assert r.status_code == 200
|
|
mids = [a["machine_id"] for a in r.json()]
|
|
assert "PROX-MAX" in mids
|
|
|
|
def test_proximity_radius_below_min(self, client):
|
|
"""radius_meters below minimum 1 → 422."""
|
|
r = client.get("/api/proximity?lat=0&lng=0&radius_meters=0")
|
|
assert r.status_code == 422
|
|
|
|
def test_proximity_radius_above_max(self, client):
|
|
"""radius_meters above maximum 50000 → 422."""
|
|
r = client.get("/api/proximity?lat=0&lng=0&radius_meters=50001")
|
|
assert r.status_code == 422
|
|
|
|
def test_proximity_results_sorted_by_distance(self, client):
|
|
"""Results are sorted nearest-first."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-FAR2",
|
|
"name": "Farther",
|
|
"latitude": 40.73, "longitude": -74.0,
|
|
})
|
|
client.post("/api/assets", json={
|
|
"machine_id": "PROX-NEAR2",
|
|
"name": "Nearer",
|
|
"latitude": 40.713, "longitude": -74.006,
|
|
})
|
|
r = client.get("/api/proximity?lat=40.7128&lng=-74.006&radius_meters=5000")
|
|
assert r.status_code == 200
|
|
mids = [a["machine_id"] for a in r.json()]
|
|
# Nearer should come first
|
|
if len(mids) >= 2:
|
|
assert mids[0] == "PROX-NEAR2"
|
|
|
|
def test_proximity_limit_50(self, client):
|
|
"""Max 50 results returned."""
|
|
# Create 55 assets within range
|
|
for i in range(55):
|
|
client.post("/api/assets", json={
|
|
"machine_id": f"PROX-{i:03d}",
|
|
"name": f"Asset {i}",
|
|
"latitude": 40.7128 + (i * 0.0001),
|
|
"longitude": -74.006,
|
|
})
|
|
r = client.get("/api/proximity?lat=40.7128&lng=-74.006&radius_meters=50000")
|
|
assert r.status_code == 200
|
|
assert len(r.json()) <= 50
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 5. Asset coordinate persistence
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestAssetCoordinates:
|
|
"""Assets store and retrieve lat/lng properly."""
|
|
|
|
def test_create_asset_with_coords(self, client):
|
|
"""Create asset with latitude/longitude."""
|
|
r = client.post("/api/assets", json={
|
|
"machine_id": "COORD-001",
|
|
"name": "Coordinated Asset",
|
|
"latitude": 40.7128, "longitude": -74.006,
|
|
})
|
|
assert r.status_code == 201
|
|
assert r.json()["latitude"] == 40.7128
|
|
assert r.json()["longitude"] == -74.006
|
|
|
|
def test_create_asset_with_only_latitude(self, client):
|
|
"""Asset with latitude but no longitude — should store both."""
|
|
r = client.post("/api/assets", json={
|
|
"machine_id": "COORD-LAT",
|
|
"name": "Lat Only",
|
|
"latitude": 40.0,
|
|
})
|
|
assert r.status_code == 201
|
|
data = r.json()
|
|
assert data["latitude"] == 40.0
|
|
assert data["longitude"] is None
|
|
|
|
def test_create_asset_with_only_longitude(self, client):
|
|
"""Asset with longitude but no latitude."""
|
|
r = client.post("/api/assets", json={
|
|
"machine_id": "COORD-LNG",
|
|
"name": "Lng Only",
|
|
"longitude": -74.0,
|
|
})
|
|
assert r.status_code == 201
|
|
data = r.json()
|
|
assert data["longitude"] == -74.0
|
|
assert data["latitude"] is None
|
|
|
|
def test_update_asset_coords(self, client):
|
|
"""Update asset coordinates."""
|
|
aid = client.post("/api/assets", json={
|
|
"machine_id": "COORD-002", "name": "Move Me",
|
|
}).json()["id"]
|
|
r = client.put(f"/api/assets/{aid}", json={
|
|
"latitude": 41.0, "longitude": -73.0,
|
|
})
|
|
assert r.status_code == 200
|
|
assert r.json()["latitude"] == 41.0
|
|
assert r.json()["longitude"] == -73.0
|
|
|
|
def test_update_asset_preserves_coords(self, client):
|
|
"""Updating asset name preserves existing coordinates."""
|
|
aid = client.post("/api/assets", json={
|
|
"machine_id": "COORD-PRES",
|
|
"name": "Keep Coords",
|
|
"latitude": 40.0, "longitude": -74.0,
|
|
}).json()["id"]
|
|
r = client.put(f"/api/assets/{aid}", json={"name": "Renamed"})
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
assert data["name"] == "Renamed"
|
|
assert data["latitude"] == 40.0
|
|
assert data["longitude"] == -74.0
|
|
|
|
def test_bulk_assets_include_coords(self, client):
|
|
"""GET /api/assets?limit=1000 returns coordinates for pin loading."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "BULK-001", "name": "Bulk A",
|
|
"latitude": 40.0, "longitude": -74.0,
|
|
})
|
|
client.post("/api/assets", json={
|
|
"machine_id": "BULK-002", "name": "Bulk B",
|
|
"latitude": 41.0, "longitude": -73.0,
|
|
})
|
|
r = client.get("/api/assets?limit=1000")
|
|
assert r.status_code == 200
|
|
with_coords = [a for a in r.json() if a["latitude"] is not None]
|
|
assert len(with_coords) == 2
|
|
|
|
def test_asset_with_null_coords_excluded(self, client):
|
|
"""Asset with null coords is valid but won't get a pin."""
|
|
r = client.post("/api/assets", json={
|
|
"machine_id": "NOCOORD", "name": "No Coord",
|
|
})
|
|
assert r.status_code == 201
|
|
assert r.json()["latitude"] is None
|
|
|
|
def test_null_coords_preserve_existing(self, client):
|
|
"""Sending null for lat/lng preserves existing values (PATCH semantics)."""
|
|
aid = client.post("/api/assets", json={
|
|
"machine_id": "NULL-PRES", "name": "Preserve Me",
|
|
"latitude": 40.0, "longitude": -74.0,
|
|
}).json()["id"]
|
|
r = client.put(f"/api/assets/{aid}", json={"latitude": None, "longitude": None})
|
|
assert r.status_code == 200
|
|
# None means "don't update" — existing values are preserved
|
|
assert r.json()["latitude"] == 40.0
|
|
assert r.json()["longitude"] == -74.0
|
|
|
|
def test_asset_coords_in_list(self, client):
|
|
"""All assets in list endpoint include lat/lng fields."""
|
|
client.post("/api/assets", json={
|
|
"machine_id": "LIST-COORD",
|
|
"name": "List Coord",
|
|
"latitude": 35.0, "longitude": 139.0,
|
|
})
|
|
r = client.get("/api/assets")
|
|
assert r.status_code == 200
|
|
for asset in r.json():
|
|
assert "latitude" in asset
|
|
assert "longitude" in asset
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# 5. Geofence User Assignment (service areas)
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class TestGeofenceUserAssignment:
|
|
"""User-to-geofence assignments for service areas."""
|
|
|
|
def _create_user(self, client, username="tech", role="technician"):
|
|
return client.post("/api/users", json={
|
|
"username": username, "password": "pass123", "role": role,
|
|
}).json()
|
|
|
|
def _create_geofence(self, client, name="Zone A", user_ids=None):
|
|
return client.post("/api/geofences", json={
|
|
"name": name,
|
|
"points": [{"lat": 40, "lng": -74}, {"lat": 40.1, "lng": -74},
|
|
{"lat": 40.1, "lng": -73.9}, {"lat": 40, "lng": -73.9}],
|
|
"color": "#ff0000",
|
|
"user_ids": user_ids or [],
|
|
}).json()
|
|
|
|
def test_create_with_single_user(self, client):
|
|
"""Create geofence with one assigned user."""
|
|
user = self._create_user(client)
|
|
gf = self._create_geofence(client, user_ids=[user["id"]])
|
|
assert len(gf["assigned_users"]) == 1
|
|
assert gf["assigned_users"][0]["username"] == "tech"
|
|
|
|
def test_create_with_multiple_users(self, client):
|
|
"""Create geofence with multiple assigned users."""
|
|
u1 = self._create_user(client, "tech1")
|
|
u2 = self._create_user(client, "tech2")
|
|
gf = self._create_geofence(client, user_ids=[u1["id"], u2["id"]])
|
|
assert len(gf["assigned_users"]) == 2
|
|
|
|
def test_create_without_users(self, client):
|
|
"""Create geofence without user assignment."""
|
|
user = self._create_user(client)
|
|
gf = self._create_geofence(client, user_ids=[])
|
|
assert gf.get("assigned_users") == []
|
|
|
|
def test_list_includes_assigned_users(self, client):
|
|
"""GET /api/geofences includes assigned_users on each geofence."""
|
|
user = self._create_user(client)
|
|
self._create_geofence(client, "Zone A", user_ids=[user["id"]])
|
|
self._create_geofence(client, "Zone B", user_ids=[])
|
|
r = client.get("/api/geofences")
|
|
assert r.status_code == 200
|
|
data = r.json()
|
|
zone_a = next(g for g in data if g["name"] == "Zone A")
|
|
zone_b = next(g for g in data if g["name"] == "Zone B")
|
|
assert len(zone_a["assigned_users"]) == 1
|
|
assert zone_b["assigned_users"] == []
|
|
|
|
def test_update_add_user(self, client):
|
|
"""Add user assignment to existing geofence."""
|
|
user = self._create_user(client)
|
|
gf = self._create_geofence(client, user_ids=[])
|
|
r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": [user["id"]]})
|
|
assert r.status_code == 200
|
|
assert len(r.json()["assigned_users"]) == 1
|
|
|
|
def test_update_remove_all_users(self, client):
|
|
"""Remove all user assignments from geofence."""
|
|
user = self._create_user(client)
|
|
gf = self._create_geofence(client, user_ids=[user["id"]])
|
|
r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": []})
|
|
assert r.status_code == 200
|
|
assert r.json()["assigned_users"] == []
|
|
|
|
def test_update_replace_users(self, client):
|
|
"""Replace one assigned user with another."""
|
|
u1 = self._create_user(client, "tech1")
|
|
u2 = self._create_user(client, "tech2")
|
|
gf = self._create_geofence(client, user_ids=[u1["id"]])
|
|
r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": [u2["id"]]})
|
|
assert r.status_code == 200
|
|
users = r.json()["assigned_users"]
|
|
assert len(users) == 1
|
|
assert users[0]["username"] == "tech2"
|
|
|
|
def test_user_geofences_list(self, client):
|
|
"""GET /api/users/:id/geofences returns user's service areas."""
|
|
user = self._create_user(client)
|
|
gf1 = self._create_geofence(client, "Zone A", user_ids=[user["id"]])
|
|
gf2 = self._create_geofence(client, "Zone B", user_ids=[user["id"]])
|
|
r = client.get(f"/api/users/{user['id']}/geofences")
|
|
assert r.status_code == 200
|
|
names = [g["name"] for g in r.json()]
|
|
assert len(names) == 2
|
|
assert "Zone A" in names
|
|
assert "Zone B" in names
|
|
|
|
def test_user_geofences_empty(self, client):
|
|
"""User with no assignments gets empty list."""
|
|
user = self._create_user(client)
|
|
r = client.get(f"/api/users/{user['id']}/geofences")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_user_geofences_not_found(self, client):
|
|
"""Non-existent user returns 404."""
|
|
r = client.get("/api/users/99999/geofences")
|
|
assert r.status_code == 404
|
|
|
|
def test_geofence_delete_cascades_assignments(self, client):
|
|
"""Deleting a geofence removes its user assignments."""
|
|
user = self._create_user(client)
|
|
gf = self._create_geofence(client, user_ids=[user["id"]])
|
|
r = client.delete(f"/api/geofences/{gf['id']}")
|
|
assert r.status_code in (200, 204)
|
|
r = client.get(f"/api/users/{user['id']}/geofences")
|
|
assert r.status_code == 200
|
|
assert r.json() == []
|
|
|
|
def test_user_delete_cascades_assignments(self, client):
|
|
"""Deleting a user removes them from geofence assignments."""
|
|
user = self._create_user(client)
|
|
gf = self._create_geofence(client, user_ids=[user["id"]])
|
|
r = client.delete(f"/api/users/{user['id']}")
|
|
assert r.status_code in (200, 204)
|
|
# Geofence should still exist but with no assigned users
|
|
r = client.get("/api/geofences")
|
|
assert r.status_code == 200
|
|
geofence = next((g for g in r.json() if g["id"] == gf["id"]), None)
|
|
assert geofence is not None, "Geofence should still exist after user delete"
|
|
assert geofence.get("assigned_users", []) == []
|
|
|
|
def test_create_geofence_invalid_user_id(self, client):
|
|
"""Creating with non-existent user ID returns 422."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "Bad Zone",
|
|
"points": [{"lat": 0, "lng": 0}, {"lat": 0, "lng": 1},
|
|
{"lat": 1, "lng": 1}, {"lat": 1, "lng": 0}],
|
|
"user_ids": [99999],
|
|
})
|
|
assert r.status_code == 422
|
|
|
|
def test_update_geofence_invalid_user_id(self, client):
|
|
"""Updating with non-existent user ID returns 422."""
|
|
gf = self._create_geofence(client)
|
|
r = client.put(f"/api/geofences/{gf['id']}", json={"user_ids": [99999]})
|
|
assert r.status_code == 422
|
|
|
|
def test_create_without_user_ids_field(self, client):
|
|
"""Create geofence omitting user_ids field — no assignments."""
|
|
r = client.post("/api/geofences", json={
|
|
"name": "No Users Field",
|
|
"points": [{"lat": 0, "lng": 0}, {"lat": 0, "lng": 1},
|
|
{"lat": 1, "lng": 1}, {"lat": 1, "lng": 0}],
|
|
"color": "#ff0000",
|
|
})
|
|
assert r.status_code == 201
|
|
assert r.json().get("assigned_users") == []
|
|
|
|
def test_update_without_user_ids_field(self, client):
|
|
"""Update geofence omitting user_ids — existing assignments unchanged."""
|
|
user = self._create_user(client)
|
|
gf = self._create_geofence(client, user_ids=[user["id"]])
|
|
# Update only name, don't touch user_ids
|
|
r = client.put(f"/api/geofences/{gf['id']}", json={"name": "Renamed"})
|
|
assert r.status_code == 200
|
|
assert r.json()["name"] == "Renamed"
|
|
assert len(r.json()["assigned_users"]) == 1
|
|
assert r.json()["assigned_users"][0]["username"] == "tech"
|