"""Integration tests for the presence feature: - POST /api/presence/heartbeat - GET /api/presence/presence - PATCH /api/presence/status Hits the real app against the configured Postgres *and* a real Redis (conftest.py brings up an ephemeral instance of each when neither is already reachable) -- presence is fundamentally "does a TTL key exist in Redis", which isn't worth faking with a mock. Also covers the regression fixed in 47ab492 ("Fix StatusUpdate field annotation and is_online batch-call mismatch"): the router used to call ``PresenceStore.is_online(ids)`` with a *list* of ids and then evaluate ``uid in online_ids`` against the returned bool, which raised a TypeError on the first real multi-id batch call, and ``StatusUpdate.status_message`` used the invalid annotation ``str | Field(...)``. Both are exercised below. """ import asyncio import pytest from fastapi.testclient import TestClient from app.main import app from app.services.presence import PresenceStore def _register(client: TestClient, username: str) -> dict: resp = client.post( "/api/auth/register", json={ "username": username, "email": f"{username}@example.com", "password": "secret123", "date_of_birth": "1990-01-01", }, ) assert resp.status_code == 201, resp.text token = resp.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} me = client.get("/api/users/me", headers=headers) assert me.status_code == 200, me.text return {"headers": headers, "id": me.json()["id"]} class TestHeartbeat: def test_heartbeat_sets_online(self): client = TestClient(app) user = _register(client, "presence_hb1") resp = client.post("/api/presence/heartbeat", json={}, headers=user["headers"]) assert resp.status_code == 200, resp.text assert resp.json() == {"status": "ok", "user_id": user["id"]} presence = client.get( "/api/presence/presence", params={"user_ids": str(user["id"])}, headers=user["headers"], ) assert presence.status_code == 200, presence.text body = presence.json()["users"] assert len(body) == 1 assert body[0]["user_id"] == user["id"] assert body[0]["is_online"] is True assert body[0]["last_seen_at"] is not None def test_requires_auth(self): client = TestClient(app) resp = client.post("/api/presence/heartbeat", json={}) assert resp.status_code == 401 def test_never_seen_user_is_offline_with_null_last_seen(self): client = TestClient(app) user = _register(client, "presence_never_hb") presence = client.get( "/api/presence/presence", params={"user_ids": str(user["id"])}, headers=user["headers"], ) assert presence.status_code == 200 body = presence.json()["users"][0] assert body["is_online"] is False assert body["last_seen_at"] is None class TestBatchPresence: """Covers the batch-call path fixed in 47ab492 (is_online_batch via MGET).""" def test_batch_query_mixes_online_offline_and_unknown_ids(self): client = TestClient(app) online_user = _register(client, "presence_batch_online") offline_user = _register(client, "presence_batch_offline") client.post("/api/presence/heartbeat", json={}, headers=online_user["headers"]) unknown_id = max(online_user["id"], offline_user["id"]) + 999_999 ids = f"{online_user['id']},{offline_user['id']},{unknown_id}" resp = client.get( "/api/presence/presence", params={"user_ids": ids}, headers=online_user["headers"], ) assert resp.status_code == 200, resp.text by_id = {u["user_id"]: u for u in resp.json()["users"]} # Unknown id is silently dropped (not in the DB), not a 500 or 404. assert unknown_id not in by_id assert by_id[online_user["id"]]["is_online"] is True assert by_id[offline_user["id"]]["is_online"] is False def test_empty_user_ids_is_a_400(self): client = TestClient(app) user = _register(client, "presence_empty_ids") resp = client.get( "/api/presence/presence", params={"user_ids": ""}, headers=user["headers"], ) assert resp.status_code == 400 class TestStatusMessage: def test_update_and_read_back_status_message(self): client = TestClient(app) user = _register(client, "presence_status1") resp = client.patch( "/api/presence/status", json={"status_message": " brb, making coffee "}, headers=user["headers"], ) assert resp.status_code == 200, resp.text # The router strips whitespace (StatusUpdate.strip_message). assert resp.json()["status_message"] == "brb, making coffee" presence = client.get( "/api/presence/presence", params={"user_ids": str(user["id"])}, headers=user["headers"], ) assert presence.json()["users"][0]["status_message"] == "brb, making coffee" def test_status_message_over_max_length_is_rejected(self): client = TestClient(app) user = _register(client, "presence_status_toolong") resp = client.patch( "/api/presence/status", json={"status_message": "x" * 141}, headers=user["headers"], ) # StatusUpdate.status_message: str | None = Field(..., max_length=140) # This is exactly the annotation 47ab492 fixed (it used to be the # invalid `str | Field(...)`, which breaks model construction # entirely rather than validating length). assert resp.status_code == 422 def test_status_message_at_max_length_is_accepted(self): client = TestClient(app) user = _register(client, "presence_status_maxlen") resp = client.patch( "/api/presence/status", json={"status_message": "x" * 140}, headers=user["headers"], ) assert resp.status_code == 200, resp.text def test_null_status_message_clears_it(self): client = TestClient(app) user = _register(client, "presence_status_clear") client.patch("/api/presence/status", json={"status_message": "hi"}, headers=user["headers"]) resp = client.patch("/api/presence/status", json={"status_message": None}, headers=user["headers"]) assert resp.status_code == 200 assert resp.json()["status_message"] is None class TestPresenceStoreTTL: """Exercises the real Redis TTL semantics directly against the service, with a short heartbeat_ttl so the test doesn't need to sleep for the real 60s default used in production. This is the cheap, real proxy for "TTL expiry means offline" the task asked for -- same code path (real Redis SET ... EX / EXISTS / MGET), just a much shorter window. """ async def test_ttl_expiry_flips_online_to_offline(self): store = PresenceStore(heartbeat_ttl=1) try: uid = 987654321 # doesn't need to exist in Postgres; Redis-only check assert await store.is_online(uid) is False await store.heartbeat(uid) assert await store.is_online(uid) is True await asyncio.sleep(1.5) assert await store.is_online(uid) is False finally: await store.close() async def test_is_online_batch_matches_is_online_semantics(self): """Regression coverage for 47ab492: is_online_batch must return a set of ids usable with `in`, not a bool.""" store = PresenceStore(heartbeat_ttl=30) try: online_uid, offline_uid = 111222333, 444555666 await store.heartbeat(online_uid) result = await store.is_online_batch([online_uid, offline_uid]) assert isinstance(result, set) assert online_uid in result assert offline_uid not in result finally: await store.close() async def test_is_online_batch_empty_list(self): store = PresenceStore() try: assert await store.is_online_batch([]) == set() finally: await store.close()