test(e2e): a plain member's shell is held end to end, and a page it cannot open lands on the feed
The suite ran only as an admin, so nothing end to end held what a member sees. A second seeded account, a member with its own session, now walks every rail page (no admin entry, no admin call, no error state) and follows links to the admin pages. It found one: a member's link to an admin page showed the feed with no rail row current, and the address bar kept naming the admin page — only the content fell back. The navigation state now derives the page on screen through each module's gate (reachablePage), so the rail, the content, the tab title and the address bar agree, on a link, a Back, and a role taken away mid-session.
This commit is contained in:
+144
-138
@@ -4,14 +4,15 @@ 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
|
||||
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) and E2E_PASSWORD (REQUIRED, no
|
||||
default — the ops wrapper exports it from the secret store); the account
|
||||
lives only in the localdev DB.
|
||||
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
|
||||
@@ -31,6 +32,7 @@ from app.reader.models import Playlist, PlaylistItem, 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(
|
||||
@@ -42,145 +44,149 @@ 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
|
||||
_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.
|
||||
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()
|
||||
|
||||
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( # noqa: T201
|
||||
json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"user_id": user.id,
|
||||
"email": EMAIL,
|
||||
"channels": channel_ids,
|
||||
"in_progress": in_progress,
|
||||
"watched": watched,
|
||||
"saved": saved,
|
||||
}
|
||||
)
|
||||
)
|
||||
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))
|
||||
|
||||
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()
|
||||
|
||||
return {
|
||||
"user_id": user.id,
|
||||
"email": email,
|
||||
"channels": channel_ids,
|
||||
"in_progress": in_progress,
|
||||
"watched": watched,
|
||||
"saved": saved,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user