Initial commit: Canteen Asset Geolocation Tool v2

This commit is contained in:
2026-05-17 18:55:28 -04:00
commit 7da3f28c6a
50 changed files with 19509 additions and 0 deletions
+752
View File
@@ -0,0 +1,752 @@
"""
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"