Files
siftlode/scripts/e2e_seed.py
T
peter 238e6f8a61 refactor(catalog): the unit's tables move into catalog/models
Channel, Subscription, Video, SearchFind, ExploredChannel and BlockedChannel (and LIVE_OR_UPCOMING)
leave app/models.py for app/catalog/models.py; app.tables registers them. The User↔Subscription
relationship names its other side as a type only. No schema change.
2026-10-19 02:03:14 +02:00

186 lines
7.3 KiB
Python

"""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 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 (default e2e@siftlode.local) and E2E_PASSWORD (REQUIRED, no
default — the ops wrapper exports it from the secret store); 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
from app.db import SessionLocal
from app.catalog.models import Channel, Subscription, Video
from app.models import AppState, User
from app.reader.models import Playlist, PlaylistItem, VideoState
from app.security import hash_password
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
PASSWORD = os.environ.get("E2E_PASSWORD", "")
if not PASSWORD:
sys.exit(
"e2e_seed: E2E_PASSWORD is not set (no default by design) — run it via `siftlode e2e-seed`"
)
N_SUBS = 3
def main() -> None:
db = SessionLocal()
try:
# 0. Mark the instance configured so the first-run setup wizard is skipped. A prod→dev
# reseed (siftlode seed-dev-from-prod) TRUNCATEs app_state — it's excluded from the
# data allow-list, being local/operational state — which would otherwise drop the box
# back into "Setup needed" and block login/UAT. Since e2e_seed always leaves a loginable
# admin below, the instance is legitimately set up. Idempotent (mirrors state._row +
# state.mark_configured). The running api process caches `configured` one-way from False,
# so it re-reads the DB and promotes on the next request — no restart needed.
app_state = db.get(AppState, 1)
if app_state is None:
app_state = AppState(id=1, sync_paused=False)
db.add(app_state)
app_state.configured = True
app_state.setup_token_hash = None
db.commit()
# 1. Upsert the account and always reset it to a clean, loginable baseline
# (email_verified + is_active + not suspended + 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.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=datetime.now(timezone.utc)
)
)
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=datetime.now(timezone.utc),
)
)
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=datetime.now(timezone.utc),
)
)
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()
# T201: this script's stdout IS its interface — the e2e runner parses this JSON.
print( # noqa: T201
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()