- Uploads: one at a time per user and ten an hour, each refused only after its body arrived; the history read an entry at a time (32 MiB cap), a Watch later list capped at YouTube's 5000, a user's kept videos at 200k; any unreadable shape is a 422, not a 500. - Quota: Watch later lookups stop at the backfill reserve and at a per-user daily share; what is not asked about waits, known videos still go in, and the shorts job retries the rest. - Staging and settling share one per-user advisory lock (no deadlock); a run settles only the rows it read, so a row staged meanwhile is never called gone. - A failed run's notice names a translated failure, never the upstream's text. - The status says when an import is queued; the page watches it to its end even when left, then reads the feed, its counts and the playlists again. - Settings: one live region, the zone kept (unavailable) while a run goes, focus handed back, a missed drop kept from the browser, an oversize file refused before it is sent. - Scheduler and run lists show Takeout runs by the importing user.
219 lines
8.4 KiB
Python
219 lines
8.4 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 two dedicated test accounts to a known baseline every run, so the Playwright
|
|
golden-master net is deterministic and never touches a real account: the admin the suite runs as,
|
|
and a plain member (`member.spec.ts`) that holds what a non-admin sees. 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), E2E_MEMBER_EMAIL (default
|
|
e2e-member@siftlode.local) and E2E_PASSWORD (REQUIRED, no default — the ops wrapper exports it from
|
|
the secret store; both accounts share it); the accounts live 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
|
|
import app.tables # noqa: F401 — every table, before the first query
|
|
from app.catalog.models import Channel, Subscription, Video
|
|
from app.models import AppState, User
|
|
from app.reader.models import Playlist, PlaylistItem, TakeoutItem, VideoState
|
|
from app.security import hash_password
|
|
|
|
EMAIL = os.environ.get("E2E_EMAIL", "e2e@siftlode.local")
|
|
MEMBER_EMAIL = os.environ.get("E2E_MEMBER_EMAIL", "e2e-member@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:
|
|
_mark_configured(db)
|
|
# 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.
|
|
admin = _seed_account(db, EMAIL, "admin", "E2E Tester")
|
|
member = _seed_account(db, MEMBER_EMAIL, "user", "E2E Member")
|
|
# T201: this script's stdout IS its interface — the e2e runner parses this JSON.
|
|
print(json.dumps({"ok": True, **admin, "member": member})) # noqa: T201
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def _mark_configured(db) -> None:
|
|
# 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()
|
|
|
|
|
|
def _seed_account(db, email: str, role: str, display_name: str) -> dict:
|
|
# 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
|
|
user.role = role
|
|
user.display_name = display_name
|
|
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))
|
|
# A Takeout import's kept videos too: a settled row keeps its outcome over a re-upload, so the
|
|
# import spec's counts would otherwise be the previous run's, not this one's.
|
|
db.execute(delete(TakeoutItem).where(TakeoutItem.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()
|
|
|
|
# 5. Videos the Takeout import marks watched: from the subscribed channels, and already cleared by
|
|
# the Shorts probe — one not yet probed would wait for it, and the import spec would count
|
|
# nothing. One per browser project: they share this account, and a video imported once is not
|
|
# marked again.
|
|
known = (
|
|
list(
|
|
db.scalars(
|
|
select(Video.id)
|
|
.where(
|
|
Video.channel_id.in_(channel_ids),
|
|
Video.shorts_probed.is_(True),
|
|
Video.is_short.is_(False),
|
|
Video.id.not_in(used | {saved} if saved else used),
|
|
)
|
|
.order_by(Video.published_at.desc())
|
|
.limit(5)
|
|
)
|
|
)
|
|
if channel_ids
|
|
else []
|
|
)
|
|
|
|
return {
|
|
"user_id": user.id,
|
|
"email": email,
|
|
"channels": channel_ids,
|
|
"in_progress": in_progress,
|
|
"watched": watched,
|
|
"saved": saved,
|
|
"takeout_known": known,
|
|
}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|