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