test(e2e): golden-master Playwright net for the Platform refactor
Locks the five flows the refactor must not break (feed, channel, player, settings, nav) so later sprints can prove they changed nothing. Adds a thin data-testid layer and a dedicated seeded non-demo account; the suite runs on the baked :8080 stack (authoritative) or :5173. No product-behaviour change.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
"""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
|
||||
user.role = "user"
|
||||
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()
|
||||
Reference in New Issue
Block a user