from datetime import datetime from fastapi.testclient import TestClient from birdie.api import _counts, _status, create_app, mount_mobile_web from birdie.models import Seat, SeatSnapshot, SeatState, Venue, VenueConfig, VenueSource, VenueType from birdie.service import BirdieService from birdie.storage import Repository def test_public_api_reports_unknown_seats_as_not_free_and_hides_camera_polygons(clock): venue = Venue( venue_id="v", name="Venue", area="Doha", type=VenueType.cowork, lat=0, lon=0, zones=["open"], source=VenueSource.recorded, ) seat = Seat( seat_id="A", venue_id="v", zone="open", polygon=[(0, 0), (1, 0), (1, 1)], ) svc = BirdieService( repo=Repository(":memory:"), clock=clock, venues={"v": VenueConfig(venue=venue, seats=[seat])}, detector=lambda image, threshold: [], persist_venues=False, ) client = TestClient(create_app(svc)) listed = client.get("/api/venues").json()[0] detail = client.get("/api/venues/v").json() assert listed["counts"]["free"] == 0 assert listed["counts"]["unknown"] == 1 assert listed["counts"]["taken"] == 0 assert detail["seats"][0]["state"] == SeatState.UNKNOWN.value assert detail["seats"][0]["status"] == "checking" assert "polygon" not in detail["seats"][0] assert client.get("/api/venues/missing").status_code == 404 def test_public_availability_has_three_groups_and_a_checking_indicator(): assert {state: _status(state) for state in SeatState} == { SeatState.UNKNOWN: "checking", SeatState.FREE: "free", SeatState.OCCUPIED: "taken", SeatState.HELD: "held", SeatState.REVIEW_REQUIRED: "held", SeatState.UNAVAILABLE: "taken", } now = datetime.fromisoformat("2026-11-07T12:00:00+00:00") states = [SeatState.FREE, SeatState.OCCUPIED, SeatState.UNAVAILABLE, SeatState.HELD, SeatState.REVIEW_REQUIRED, SeatState.UNKNOWN] snapshots = [SeatSnapshot(venue_id="v", seat_id=str(i), zone="open", has_outlet=False, state=state, since=now, updated_at=now) for i, state in enumerate(states)] counts = _counts(snapshots) assert (counts["free"], counts["taken"], counts["held_or_review"], counts["checking"]) == (1, 2, 2, 1) assert counts["free"] + counts["taken"] + counts["held_or_review"] + counts["checking"] == counts["total"] def test_mobile_web_is_served_after_api_routes(clock): venue = Venue( venue_id="v", name="Venue", area="Doha", type=VenueType.cowork, lat=0, lon=0, zones=["open"], source=VenueSource.recorded, ) svc = BirdieService( repo=Repository(":memory:"), clock=clock, venues={"v": VenueConfig(venue=venue, seats=[])}, persist_venues=False, detector=lambda image, threshold: [], ) app = create_app(svc) mount_mobile_web(app) client = TestClient(app) assert "Birdie" in client.get("/").text assert client.get("/api/venues").status_code == 200 def test_staff_override_requires_token_and_changes_availability(clock, monkeypatch): venue = Venue( venue_id="v", name="Venue", area="Doha", type=VenueType.cowork, lat=0, lon=0, zones=["open"], source=VenueSource.recorded, ) seat = Seat(seat_id="A", venue_id="v", zone="open") svc = BirdieService( repo=Repository(":memory:"), clock=clock, venues={"v": VenueConfig(venue=venue, seats=[seat])}, detector=lambda image, threshold: [], persist_venues=False, ) client = TestClient(create_app(svc)) monkeypatch.setenv("BIRDIE_ADMIN_TOKEN", "test-token") response = client.put("/api/venues/v/seats/A/unavailable", json={"unavailable": True}) assert response.status_code == 401 response = client.put( "/api/venues/v/seats/A/unavailable", json={"unavailable": True}, headers={"Authorization": "Bearer test-token"}, ) assert response.status_code == 200 assert response.json()["state"] == SeatState.UNAVAILABLE.value assert svc.counts("v")[SeatState.FREE] == 0 def test_forecast_starts_from_the_live_count_and_covers_three_hours(clock): venue = Venue( venue_id="v", name="Venue", area="Doha", type=VenueType.library, lat=0, lon=0, zones=["open"], source=VenueSource.simulated, drive_minutes=15, ) seats = [Seat(seat_id=f"A{i}", venue_id="v", zone="open") for i in range(1, 9)] svc = BirdieService( repo=Repository(":memory:"), clock=clock, venues={"v": VenueConfig(venue=venue, seats=seats)}, detector=lambda image, threshold: [], persist_venues=False, ) client = TestClient(create_app(svc)) listed = client.get("/api/venues").json()[0]["forecast"] detail = client.get("/api/venues/v").json() timeline = detail["forecast"]["timeline"] assert listed["arrival"]["minutes"] == 15 assert "timeline" not in listed assert [p["minutes"] for p in timeline] == [0, 30, 60, 90, 120, 150, 180] assert timeline[0]["expected_free"] == detail["counts"]["usable_study_spaces"] assert all(0 <= p["low"] <= p["expected_free"] <= p["high"] <= 8 for p in timeline)