forked from retoor/devplacepy
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:
@@ -349,6 +349,28 @@ def test_maintenance_block_recorded(seeded_db):
|
||||
assert event["result"] == "denied"
|
||||
|
||||
|
||||
def test_feed_avatar_has_presence_dot(app_server):
|
||||
s, name = _member()
|
||||
uid = _db_user(name)["uid"]
|
||||
s.post(
|
||||
f"{BASE_URL}/posts/create",
|
||||
data={"title": _unique("dot"), "content": "presence dot feed post", "topic": "devlog"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
html = s.get(f"{BASE_URL}/feed").text
|
||||
assert "presence-dot" in html
|
||||
assert f'data-presence-uid="{uid}"' in html
|
||||
|
||||
|
||||
def test_feed_shows_online_now_section(app_server):
|
||||
s, name = _member()
|
||||
html = s.get(f"{BASE_URL}/feed").text
|
||||
assert "online-users" in html
|
||||
assert "data-online-users-list" in html
|
||||
assert "data-online-count" in html
|
||||
assert f'class="online-user" title="{name}"' in html
|
||||
|
||||
|
||||
def test_feed_shows_poll_results_without_voting(app_server):
|
||||
s, _ = _session_polls()
|
||||
title = f"feedpoll-{int(time.time() * 1000)}"
|
||||
|
||||
@@ -154,3 +154,29 @@ def test_unauthenticated_json_request_is_401_not_redirect(app_server):
|
||||
# browser guest still redirects to login
|
||||
rh = requests.get(f"{BASE_URL}/messages", allow_redirects=False)
|
||||
assert rh.status_code == 303
|
||||
|
||||
|
||||
def test_messages_conversation_renders_presence_indicator(app_server):
|
||||
sender, _ = _member()
|
||||
_, other_name = _member()
|
||||
other_uid = _db_user(other_name)["uid"]
|
||||
r = sender.get(f"{BASE_URL}/messages?with_uid={other_uid}")
|
||||
assert r.status_code == 200
|
||||
assert "messages-presence" in r.text
|
||||
assert f'data-presence-uid="{other_uid}"' in r.text
|
||||
|
||||
|
||||
def test_messages_conversation_list_avatar_has_presence_dot(app_server):
|
||||
sender, _ = _member()
|
||||
_, other_name = _member()
|
||||
other_uid = _db_user(other_name)["uid"]
|
||||
sent = sender.post(
|
||||
f"{BASE_URL}/messages/send",
|
||||
data={"receiver_uid": other_uid, "content": "hi there"},
|
||||
allow_redirects=True,
|
||||
)
|
||||
assert sent.status_code == 200, sent.text[:300]
|
||||
html = sender.get(f"{BASE_URL}/messages").text
|
||||
assert "conversation-item" in html
|
||||
assert "presence-dot" in html
|
||||
assert f'data-presence-uid="{other_uid}"' in html
|
||||
|
||||
@@ -267,6 +267,106 @@ def test_activity_comment_card_renders_overlay_link(app_server):
|
||||
assert f'class="card-link" href="/posts/{post_slug}#comment-{comment_uid}"' in r.text
|
||||
|
||||
|
||||
def test_profile_json_exposes_online_presence(app_server):
|
||||
import time
|
||||
|
||||
name = f"pres{int(time.time() * 1000)}"
|
||||
session = requests.Session()
|
||||
session.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
r = session.get(
|
||||
f"{BASE_URL}/profile/{name}", headers={"Accept": "application/json"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["profile_online"] is True
|
||||
assert body["profile_user"]["last_seen"]
|
||||
|
||||
|
||||
def test_profile_json_offline_when_last_seen_stale(app_server):
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from devplacepy.config import PRESENCE_TIMEOUT_SECONDS
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
owner = _seed_owner()
|
||||
username = get_table("users").find_one(uid=owner)["username"]
|
||||
stale = (
|
||||
datetime.now(timezone.utc)
|
||||
- timedelta(seconds=PRESENCE_TIMEOUT_SECONDS + 120)
|
||||
).isoformat()
|
||||
get_table("users").update({"uid": owner, "last_seen": stale}, ["uid"])
|
||||
refresh_snapshot()
|
||||
|
||||
r = requests.get(
|
||||
f"{BASE_URL}/profile/{username}", headers={"Accept": "application/json"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["profile_online"] is False
|
||||
assert body["profile_user"]["last_seen"] == stale
|
||||
|
||||
|
||||
def test_profile_hero_avatar_has_presence_dot(app_server):
|
||||
import time
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
name = f"phero{int(time.time() * 1000)}"
|
||||
s = requests.Session()
|
||||
s.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
refresh_snapshot()
|
||||
uid = get_table("users").find_one(username=name)["uid"]
|
||||
html = s.get(f"{BASE_URL}/profile/{name}").text
|
||||
assert "profile-avatar-wrap" in html
|
||||
assert "presence-dot" in html
|
||||
assert f'data-presence-uid="{uid}"' in html
|
||||
|
||||
|
||||
def test_followers_list_avatar_has_presence_dot(app_server):
|
||||
import time
|
||||
from devplacepy.database import get_table, refresh_snapshot
|
||||
|
||||
stamp = int(time.time() * 1000)
|
||||
target = f"folt{stamp}"
|
||||
follower = f"folf{stamp}"
|
||||
st = requests.Session()
|
||||
sf = requests.Session()
|
||||
for sess, name in ((st, target), (sf, follower)):
|
||||
sess.post(
|
||||
f"{BASE_URL}/auth/signup",
|
||||
data={
|
||||
"username": name,
|
||||
"email": f"{name}@t.dev",
|
||||
"password": "secret123",
|
||||
"confirm_password": "secret123",
|
||||
},
|
||||
allow_redirects=True,
|
||||
)
|
||||
sf.post(f"{BASE_URL}/follow/{target}", allow_redirects=True)
|
||||
refresh_snapshot()
|
||||
follower_uid = get_table("users").find_one(username=follower)["uid"]
|
||||
html = st.get(f"{BASE_URL}/profile/{target}?tab=followers").text
|
||||
assert "follow-user" in html
|
||||
assert f'data-presence-uid="{follower_uid}"' in html
|
||||
assert "data-presence-last-seen" in html
|
||||
|
||||
|
||||
def test_viewing_profile_marks_notification_read(app_server):
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -906,3 +906,14 @@ def test_feed_pagination_emits_rel_next(page, app_server):
|
||||
assert nxt.count() == 1
|
||||
href = nxt.get_attribute("href")
|
||||
assert "before=" in href and f"topic={topic}" in href, href
|
||||
|
||||
|
||||
def test_feed_online_now_widget_lists_current_user(alice):
|
||||
page, user = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
widget = page.locator(".online-users")
|
||||
expect(widget).to_be_visible()
|
||||
expect(
|
||||
page.locator(f".online-user[title='{user['username']}']")
|
||||
).to_have_count(1)
|
||||
expect(page.locator(".online-users .presence-dot.online").first).to_be_visible()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import re
|
||||
from tests.conftest import BASE_URL
|
||||
FOLLOW = "form[action='/follow/bob_test'] button"
|
||||
UNFOLLOW = "form[action='/follow/unfollow/bob_test'] button"
|
||||
@@ -7,6 +8,25 @@ from playwright.sync_api import expect
|
||||
from tests.e2e.post import create_post
|
||||
|
||||
|
||||
def test_profile_shows_online_presence(alice):
|
||||
page, user = alice
|
||||
page.goto(
|
||||
f"{BASE_URL}/profile/{user['username']}", wait_until="domcontentloaded"
|
||||
)
|
||||
presence = page.locator(".profile-presence")
|
||||
expect(presence).to_be_visible()
|
||||
expect(presence).to_have_class(re.compile(r"\bonline\b"))
|
||||
expect(presence).to_have_text("online")
|
||||
|
||||
|
||||
def test_nav_avatar_shows_online_presence_dot(alice):
|
||||
page, _ = alice
|
||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||
nav_dot = page.locator(".topnav-user .presence-dot")
|
||||
expect(nav_dot).to_be_visible()
|
||||
expect(nav_dot).to_have_class(re.compile(r"\bonline\b"))
|
||||
|
||||
|
||||
def _post_comment(page, body):
|
||||
textarea = page.locator(".comment-form textarea[name='content']")
|
||||
textarea.fill(body)
|
||||
|
||||
@@ -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