feat: add online presence tracking with last_seen column and configurable timeout
Add `last_seen` column to users table with index, implement `set_last_seen` and `get_online_users` database functions, expose presence config env vars (`PRESENCE_TIMEOUT_SECONDS`, `PRESENCE_ONLINE_LIMIT`, `PRESENCE_ONLINE_MARGIN_SECONDS`), include `last_seen` in follow list responses, and update profile docs to mention online indicator.
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import uuid_utils
|
||||
|
||||
from devplacepy.config import PRESENCE_TIMEOUT_SECONDS, PRESENCE_WRITE_SECONDS
|
||||
from devplacepy.database import get_table
|
||||
from devplacepy.services import presence
|
||||
|
||||
|
||||
def _iso(seconds_ago):
|
||||
return (datetime.now(timezone.utc) - timedelta(seconds=seconds_ago)).isoformat()
|
||||
|
||||
|
||||
def test_is_online_recent():
|
||||
assert presence.is_online({"last_seen": _iso(5)}) is True
|
||||
|
||||
|
||||
def test_is_online_stale():
|
||||
assert presence.is_online({"last_seen": _iso(PRESENCE_TIMEOUT_SECONDS + 30)}) is False
|
||||
|
||||
|
||||
def test_is_online_missing_and_empty():
|
||||
assert presence.is_online({"last_seen": ""}) is False
|
||||
assert presence.is_online({}) is False
|
||||
assert presence.is_online(None) is False
|
||||
|
||||
|
||||
def test_is_online_unparseable_is_offline():
|
||||
assert presence.is_online({"last_seen": "not-a-timestamp"}) is False
|
||||
|
||||
|
||||
def test_seconds_since_none_on_missing_or_bad():
|
||||
assert presence.seconds_since(None) is None
|
||||
assert presence.seconds_since("") is None
|
||||
assert presence.seconds_since("garbage") is None
|
||||
assert presence.seconds_since(_iso(10)) >= 10
|
||||
|
||||
|
||||
def test_touch_writes_once_then_throttles(local_db):
|
||||
uid = str(uuid_utils.uuid7())
|
||||
users = get_table("users")
|
||||
users.insert(
|
||||
{
|
||||
"uid": uid,
|
||||
"username": f"presence_{uid[:8]}",
|
||||
"email": f"{uid[:8]}@presence.test",
|
||||
"last_seen": None,
|
||||
}
|
||||
)
|
||||
presence._last_write.pop(uid, None)
|
||||
|
||||
presence.touch(uid)
|
||||
first = users.find_one(uid=uid)["last_seen"]
|
||||
assert first
|
||||
|
||||
presence.touch(uid)
|
||||
assert users.find_one(uid=uid)["last_seen"] == first
|
||||
|
||||
presence._last_write[uid] = presence.time.monotonic() - PRESENCE_WRITE_SECONDS - 1
|
||||
presence.touch(uid)
|
||||
second = users.find_one(uid=uid)["last_seen"]
|
||||
assert second >= first
|
||||
assert presence.is_online(users.find_one(uid=uid)) is True
|
||||
|
||||
|
||||
def test_get_online_users_filters_by_cutoff(local_db):
|
||||
from devplacepy.database import get_online_users
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
fresh = str(uuid_utils.uuid7())
|
||||
stale = str(uuid_utils.uuid7())
|
||||
users = get_table("users")
|
||||
users.insert(
|
||||
{"uid": fresh, "username": f"onl_{fresh[:8]}", "email": f"{fresh[:8]}@o.test", "last_seen": now.isoformat()}
|
||||
)
|
||||
users.insert(
|
||||
{"uid": stale, "username": f"onl_{stale[:8]}", "email": f"{stale[:8]}@o.test", "last_seen": (now - timedelta(seconds=99999)).isoformat()}
|
||||
)
|
||||
cutoff = (now - timedelta(seconds=60)).isoformat()
|
||||
online = {u["uid"] for u in get_online_users(cutoff, limit=1000)}
|
||||
assert fresh in online
|
||||
assert stale not in online
|
||||
|
||||
|
||||
def test_presence_online_users_includes_recent(local_db):
|
||||
fresh = str(uuid_utils.uuid7())
|
||||
get_table("users").insert(
|
||||
{"uid": fresh, "username": f"po_{fresh[:8]}", "email": f"{fresh[:8]}@o.test", "last_seen": datetime.now(timezone.utc).isoformat()}
|
||||
)
|
||||
assert fresh in {u["uid"] for u in presence.online_users(limit=1000)}
|
||||
|
||||
|
||||
def test_online_users_sorted_alphabetically(local_db):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
users = get_table("users")
|
||||
made = []
|
||||
for label in ("zeta", "alpha", "mike"):
|
||||
uid = str(uuid_utils.uuid7())
|
||||
name = f"srt{label}{uid[:6]}"
|
||||
users.insert({"uid": uid, "username": name, "email": f"{uid[:8]}@o.test", "last_seen": now})
|
||||
made.append(name)
|
||||
listed = [u["username"] for u in presence.online_users(limit=1000) if u["username"] in made]
|
||||
assert listed == sorted(made, key=str.lower)
|
||||
|
||||
|
||||
def test_stays_online_hysteresis():
|
||||
from devplacepy.config import (
|
||||
PRESENCE_ONLINE_MARGIN_SECONDS,
|
||||
PRESENCE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
within = PRESENCE_TIMEOUT_SECONDS - 5
|
||||
band = PRESENCE_TIMEOUT_SECONDS + 5
|
||||
beyond = PRESENCE_TIMEOUT_SECONDS + PRESENCE_ONLINE_MARGIN_SECONDS + 5
|
||||
# fresh activity: online regardless of prior state
|
||||
assert presence.stays_online(within, was_online=False) is True
|
||||
assert presence.stays_online(within, was_online=True) is True
|
||||
# in the grace band: only an already-online user stays online (hysteresis)
|
||||
assert presence.stays_online(band, was_online=False) is False
|
||||
assert presence.stays_online(band, was_online=True) is True
|
||||
# beyond the grace: offline even if previously online
|
||||
assert presence.stays_online(beyond, was_online=True) is False
|
||||
# no last_seen: always offline
|
||||
assert presence.stays_online(None, was_online=True) is False
|
||||
|
||||
|
||||
def test_online_candidates_uses_grace_window(local_db):
|
||||
from devplacepy.config import (
|
||||
PRESENCE_ONLINE_MARGIN_SECONDS,
|
||||
PRESENCE_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
band = str(uuid_utils.uuid7())
|
||||
beyond = str(uuid_utils.uuid7())
|
||||
users = get_table("users")
|
||||
users.insert(
|
||||
{"uid": band, "username": f"cb_{band[:8]}", "email": f"{band[:8]}@o.test",
|
||||
"last_seen": (now - timedelta(seconds=PRESENCE_TIMEOUT_SECONDS + 3)).isoformat()}
|
||||
)
|
||||
users.insert(
|
||||
{"uid": beyond, "username": f"cb_{beyond[:8]}", "email": f"{beyond[:8]}@o.test",
|
||||
"last_seen": (now - timedelta(seconds=PRESENCE_TIMEOUT_SECONDS + PRESENCE_ONLINE_MARGIN_SECONDS + 30)).isoformat()}
|
||||
)
|
||||
candidates = {u["uid"] for u in presence.online_candidates(limit=1000)}
|
||||
strict = {u["uid"] for u in presence.online_users(limit=1000)}
|
||||
# a grace-band user is a candidate (for hysteresis) but NOT strictly online
|
||||
assert band in candidates
|
||||
assert band not in strict
|
||||
# a user past the grace window is in neither
|
||||
assert beyond not in candidates
|
||||
Reference in New Issue
Block a user