f34a4806ef
Recovers a real August feature that was never merged (3 WIP checkpoints + fix commit47ab492, 225 commits behind main): a Redis-backed heartbeat presence store plus /api/presence/{heartbeat,presence,status} endpoints. Ported onto current main's conventions rather than rebuilt: - app/models/user.py: last_seen_at/status_message columns (unchanged from the orphaned branch; still compatible with the current model). - alembic/versions/0006_add_presence_columns.py: same column migration, rebased as the new head after 0005_add_world_content_tables (was 2a9b9873244a off 0001_initial). - app/routers/presence.py: same three endpoints, updated to the DbDep pattern other routers (avatar.py) now use; retains the47ab492fix (is_online_batch via MGET, correct StatusUpdate Field annotation). - app/services/presence.py: real adaptation beyond a mechanical port -- the original PresenceStore required an explicit connect() that nothing ever called (main.py's lifespan never wired it up), so every real request would have raised "not connected". Made the Redis client connect lazily on first use instead, and cache it per running event loop rather than globally: this repo's TestClient(app) pattern (no `with` block) can dispatch different requests onto different event loops, which broke a single cached connection the same way NullPool's comment in conftest.py describes for asyncpg. Dropped the unused get_presence()/ update_status_message() methods and the never-wired init/shutdown singleton helpers -- dead code even in the original. - tests/conftest.py: extended the existing ephemeral-Postgres bootstrap to also bring up ephemeral Redis (docker-compose.yml already defined the service) when REDIS_URL isn't already reachable, so `pytest` stays hermetic. - tests/test_presence.py: heartbeat sets online, batch presence over mixed online/offline/unknown ids (the exact47ab492regression), status message update/strip/length-limit, and TTL expiry via a short-TTL PresenceStore instance (real Redis SET/EXISTS/MGET, no 60s sleep needed). Verified: full backend pytest suite (144 tests) passes against a fresh Postgres+Redis. Also drove the real endpoints end-to-end against a running uvicorn + docker-compose Postgres/Redis: heartbeat -> online, batch query, forced TTL expiry -> offline with last_seen_at retained, status message trim + 422 over 140 chars. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
222 lines
8.2 KiB
Python
222 lines
8.2 KiB
Python
"""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()
|