"""Idempotent seed for the E2E characterization (golden-master) account. Run INSIDE the api container — it needs the app's models + the argon2 hasher: docker exec -i siftlode-api-1 python - < scripts/e2e_seed.py Resets a dedicated, NON-demo test account to a known baseline every run, so the Playwright golden-master net is deterministic and never touches a real account. Self-healing: a crashed spec that left the account dirty is cleaned on the next run. Prints one JSON line describing what it seeded (the wrapper / global-setup reads it). Credentials come from env (E2E_EMAIL / E2E_PASSWORD) with local-only defaults; the account lives only in the localdev DB. """ import json import os import sys from datetime import datetime, timezone sys.path.insert(0, "/app") from sqlalchemy import delete, func, select # noqa: E402 from app.db import SessionLocal # noqa: E402 from app.models import ( # noqa: E402 Channel, Playlist, PlaylistItem, Subscription, User, Video, VideoState, ) from app.security import hash_password # noqa: E402 EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local") PASSWORD = os.environ.get("E2E_PASSWORD", "e2e-golden-master") N_SUBS = 3 def _now() -> datetime: return datetime.now(timezone.utc) def main() -> None: db = SessionLocal() try: # 1. Upsert the account and always reset it to a clean, loginable baseline # (email_verified + is_active + not suspended + not demo + password set). user = db.scalar(select(User).where(User.email == EMAIL)) if user is None: user = User(email=EMAIL) db.add(user) user.password_hash = hash_password(PASSWORD) user.email_verified = True user.is_active = True user.is_suspended = False user.is_demo = False user.google_sub = None # Admin so the E2E can reach the admin-only pages (scheduler / config / users / audit) and # thus the render ternary's admin branches — the primary real user (npeter83) is admin too. user.role = "admin" user.display_name = "E2E Tester" user.preferences = {"language": "en"} db.commit() # 2. Subscriptions: the top-N organic channels by catalog size — deterministic, and # >1 so a channel filter visibly narrows the feed. Reset each run. top = db.execute( select(Video.channel_id, func.count().label("n")) .join(Channel, Channel.id == Video.channel_id) .where(Channel.from_search.is_(False), Channel.from_explore.is_(False)) .group_by(Video.channel_id) .order_by(func.count().desc()) .limit(N_SUBS) ).all() channel_ids = [row[0] for row in top] db.execute(delete(Subscription).where(Subscription.user_id == user.id)) for cid in channel_ids: db.add(Subscription(user_id=user.id, channel_id=cid, subscribed_at=_now())) db.commit() # 3. Deterministic watch state (reset each run): # - one in-progress video (drives the resume bar + "in progress" filter) from the # first channel — status stays "new" so it still counts as unwatched; # - one WATCHED video from the second channel, so the "watched" show-filter is a # real, non-empty narrowing of the feed (and the "all" vs "unwatched" split exists). db.execute(delete(VideoState).where(VideoState.user_id == user.id)) def _newest(channel_id: str, exclude: set[str]) -> Video | None: conds = [ Video.channel_id == channel_id, Video.duration_seconds.is_not(None), Video.live_status == "none", ] if exclude: conds.append(Video.id.not_in(exclude)) return db.scalar( select(Video) .where(*conds) .order_by(Video.published_at.desc()) .limit(1) ) used: set[str] = set() in_progress = None watched = None if channel_ids: vid = _newest(channel_ids[0], used) if vid is not None: db.add( VideoState( user_id=user.id, video_id=vid.id, status="new", position_seconds=42, progress_updated_at=_now(), ) ) in_progress = vid.id used.add(vid.id) if len(channel_ids) > 1: wv = _newest(channel_ids[1], used) if wv is not None: db.add( VideoState( user_id=user.id, video_id=wv.id, status="watched", watched_at=_now(), ) ) watched = wv.id used.add(wv.id) # 4. One "saved" video (Watch-later membership drives the saved marker) from the last # subscribed channel. Reset the built-in playlist's items each run. wl = db.scalar( select(Playlist).where( Playlist.user_id == user.id, Playlist.kind == "watch_later" ) ) if wl is None: wl = Playlist(user_id=user.id, name="Watch later", kind="watch_later") db.add(wl) db.commit() db.execute(delete(PlaylistItem).where(PlaylistItem.playlist_id == wl.id)) saved = None if channel_ids: svid = _newest(channel_ids[-1], used) if svid is not None: db.add(PlaylistItem(playlist_id=wl.id, video_id=svid.id, position=0)) saved = svid.id db.commit() print( json.dumps( { "ok": True, "user_id": user.id, "email": EMAIL, "channels": channel_ids, "in_progress": in_progress, "watched": watched, "saved": saved, } ) ) finally: db.close() if __name__ == "__main__": main()