diff --git a/frontend/e2e/README.md b/frontend/e2e/README.md index 6f8116fc..6e47b248 100644 --- a/frontend/e2e/README.md +++ b/frontend/e2e/README.md @@ -57,9 +57,13 @@ admin so the suite can reach the admin-only pages / render branches, matching th user), reset to a deterministic baseline every run by [`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed, -one in-progress + one watched + one saved video, prefs pinned to `language: en`. Credentials come -from `E2E_EMAIL` (default `e2e@siftlode.local`) and `E2E_PASSWORD` — **required, no default**: run -the suite through the ops wrapper, which exports it from the secret store. Override the container name with +one in-progress + one watched + one saved video, prefs pinned to `language: en`. A second account, +the plain **member** `e2e-member@siftlode.local`, gets the same baseline and its own session +(`e2e/.auth/member.json`); only `member.spec.ts` runs as it, holding what a non-admin sees (no admin +entry, no admin call, an admin link lands on the feed). Credentials come from `E2E_EMAIL` (default +`e2e@siftlode.local`), `E2E_MEMBER_EMAIL` (default `e2e-member@siftlode.local`) and `E2E_PASSWORD` +(both accounts) — **required, no default**: run the suite through the ops wrapper, which exports it +from the secret store. Override the container name with `E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`. ## Selectors diff --git a/frontend/e2e/global.setup.ts b/frontend/e2e/global.setup.ts index 5c706ecf..4a292d5e 100644 --- a/frontend/e2e/global.setup.ts +++ b/frontend/e2e/global.setup.ts @@ -1,20 +1,23 @@ -import { test as setup, expect } from "@playwright/test"; +import { test as setup, expect, type Page } from "@playwright/test"; import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { MEMBER_STORAGE } from "./helpers"; import { retryConnect } from "./retryConnect"; /** * One-time setup for the characterization net (the `setup` project the tests depend on): * 1. preflight — the localdev :8080 stack must be up (owned by `siftlode dev`); - * 2. seed — reset the dedicated E2E account to a known baseline, in-container (idempotent); - * 3. authenticate ONCE via password-login and persist the session + `en` locale to - * storageState, so the real specs never re-auth (auth endpoints are rate-limited). + * 2. seed — reset the dedicated E2E accounts to a known baseline, in-container (idempotent); + * 3. authenticate each ONCE via password-login and persist its session + `en` locale to its own + * storageState, so the real specs never re-auth (auth endpoints are rate-limited). The suite + * runs as the admin; `member.spec.ts` runs as the plain member, from MEMBER_STORAGE. */ const STORAGE = "e2e/.auth/state.json"; const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local"; +const MEMBER_EMAIL = process.env.E2E_MEMBER_EMAIL || "e2e-member@siftlode.local"; // No default: a password committed to a public repo is not a credential. The ops wrapper // (`siftlode e2e` / `check-full`) exports it from the secret store. const PASSWORD = process.env.E2E_PASSWORD ?? ""; @@ -27,14 +30,43 @@ const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py"); // The in-container seed only makes sense when the target is the local stack. const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL); -setup("seed + authenticate", async ({ page }) => { +/** Sign `email` in on `page`'s context and save that context as `path`. */ +async function signIn(page: Page, email: string, path: string): Promise { + // These requests run in Node and share the page's cookie jar — so the `session` cookie the login + // receives is what storageState saves below — and a localhost connect the machine dropped is + // tried again (retryConnect). + const api = retryConnect(page.request); + const login = await api.post("/auth/password-login", { data: { email, password: PASSWORD } }); + expect( + login.ok(), + `password-login failed for ${email} — is the E2E account seeded?`, + ).toBeTruthy(); + + const me = await (await api.get("/api/me")).json(); + expect(me.authenticated, `session not established after login (${email})`).toBeTruthy(); + + // Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay that + // would intercept every click — the test accounts have no YouTube connection, so it auto-opens), + // and opt the account out of starting on Home. A bare URL lands on Home, and the feed, channel + // and player flows this net locks all open "/" expecting the feed; the opt-out is the one + // Settings → Appearance → Startup writes. Both flags are per-account: + // `siftlode.onboarding.dismissed.`, `siftlode.startOnHome.`. + await page.goto("/"); + await page.evaluate((id: number) => { + localStorage.setItem("siftlode.lang", "en"); + localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1"); + localStorage.setItem(`siftlode.startOnHome.${id}`, "0"); + }, me.id); + mkdirSync(dirname(path), { recursive: true }); + await page.context().storageState({ path }); +} + +setup("seed + authenticate", async ({ page, browser }) => { expect( PASSWORD, "E2E_PASSWORD is not set — run the suite through `siftlode e2e` (it reads the secret store)", ).not.toBe(""); - // These requests run in Node (sharing the page's cookie jar), so a localhost connect the machine - // dropped is tried again (retryConnect). const api = retryConnect(page.request); // 1. Preflight: fail fast (with a useful message) if the stack isn't running. @@ -55,6 +87,8 @@ setup("seed + authenticate", async ({ page }) => { "-e", `E2E_EMAIL=${EMAIL}`, "-e", + `E2E_MEMBER_EMAIL=${MEMBER_EMAIL}`, + "-e", `E2E_PASSWORD=${PASSWORD}`, CONTAINER, "python", @@ -69,28 +103,12 @@ setup("seed + authenticate", async ({ page }) => { } } - // 3. Authenticate. page.request shares the browser context's cookie jar, so the `session` - // cookie it receives is captured by storageState below. - const login = await api.post("/auth/password-login", { - data: { email: EMAIL, password: PASSWORD }, - }); - expect(login.ok(), "password-login failed — is the E2E account seeded?").toBeTruthy(); - - const me = await (await api.get("/api/me")).json(); - expect(me.authenticated, "session not established after login").toBeTruthy(); - - // 4. Pin the UI locale, suppress the first-login onboarding wizard (a full-screen overlay - // that would intercept every click — the test account has no YouTube connection, so it - // auto-opens), and opt the account out of starting on Home. A bare URL lands on - // Home, and the feed, channel and player flows this net locks all open "/" expecting the - // feed; the opt-out is the one Settings → Appearance → Startup writes. Both flags are - // per-account: `siftlode.onboarding.dismissed.`, `siftlode.startOnHome.`. - await page.goto("/"); - await page.evaluate((id: number) => { - localStorage.setItem("siftlode.lang", "en"); - localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1"); - localStorage.setItem(`siftlode.startOnHome.${id}`, "0"); - }, me.id); - mkdirSync(dirname(STORAGE), { recursive: true }); - await page.context().storageState({ path: STORAGE }); + // 3. Authenticate each account in its own context: one cookie jar holds one session. + await signIn(page, EMAIL, STORAGE); + const memberContext = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + try { + await signIn(await memberContext.newPage(), MEMBER_EMAIL, MEMBER_STORAGE); + } finally { + await memberContext.close(); + } }); diff --git a/frontend/e2e/helpers.ts b/frontend/e2e/helpers.ts index d0681f04..3e126291 100644 --- a/frontend/e2e/helpers.ts +++ b/frontend/e2e/helpers.ts @@ -1,5 +1,8 @@ import { expect, type Page } from "@playwright/test"; +/** The plain member's saved session (global.setup.ts signs it in); `member.spec.ts` runs from it. */ +export const MEMBER_STORAGE = "e2e/.auth/member.json"; + /** Open the app and wait for the authenticated shell (the nav rail) to be ready. */ export async function openApp(page: Page, path = "/"): Promise { await page.goto(path); diff --git a/frontend/e2e/member.spec.ts b/frontend/e2e/member.spec.ts new file mode 100644 index 00000000..c4dd40d6 --- /dev/null +++ b/frontend/e2e/member.spec.ts @@ -0,0 +1,78 @@ +import { test, expect, type Page, type Request, type Response } from "@playwright/test"; +import { MEMBER_STORAGE, openApp } from "./helpers"; + +// What a plain member (not an admin) sees. The rest of the suite runs as the admin, so without this +// spec nothing end to end holds the member's shell: the backend refuses the admin routes (pytest), +// but a member shown an admin entry, sent to an admin page or met by a refused request would pass. +test.use({ storageState: MEMBER_STORAGE }); + +const ADMIN_PAGES = ["scheduler", "config", "users", "activity"]; +// The pages every member has; Plex joins them only where the instance enables it. +const CORE = [ + "home", + "feed", + "channels", + "playlists", + "notifications", + "files", + "stats", + "settings", +]; + +/** Collect every admin call and every refused response the page makes while `run` goes. */ +async function watchRequests(page: Page, run: () => Promise): Promise { + const bad: string[] = []; + const onRequest = (r: Request) => { + if (new URL(r.url()).pathname.startsWith("/api/admin")) bad.push(`admin call ${r.url()}`); + }; + const onResponse = (r: Response) => { + if (r.status() === 401 || r.status() === 403) bad.push(`${r.status()} ${r.url()}`); + }; + page.on("request", onRequest); + page.on("response", onResponse); + try { + await run(); + } finally { + page.off("request", onRequest); + page.off("response", onResponse); + } + return bad; +} + +test.describe("a member", () => { + test("gets no admin entry, and every page of the rail opens without an error", async ({ + page, + }) => { + const bad = await watchRequests(page, async () => { + await openApp(page); + await expect(page.getByTestId("nav-admin")).toHaveCount(0); + + const rail = page + .getByRole("navigation", { name: "Primary navigation" }) + .locator('[data-testid^="nav-"]'); + const ids = (await rail.evaluateAll((els) => els.map((el) => el.getAttribute("data-testid")))) + .filter((id): id is string => id !== null) + .map((id) => id.replace(/^nav-/, "")); + expect(ids).toEqual(expect.arrayContaining(CORE)); + expect(ids.filter((id) => ADMIN_PAGES.includes(id))).toEqual([]); + + for (const id of ids) { + await page.getByTestId(`nav-${id}`).click(); + await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page"); + // An error state is a `role=alert` StateMessage; a page that failed to load shows one. + await expect(page.getByRole("alert"), `error state on ${id}`).toHaveCount(0); + } + }); + expect(bad).toEqual([]); + }); + + test("a link to an admin page opens no admin page", async ({ page }) => { + for (const id of ADMIN_PAGES) { + const bad = await watchRequests(page, () => openApp(page, `/?page=${id}`)); + expect(bad, id).toEqual([]); + await expect(page.getByTestId("nav-admin")).toHaveCount(0); + // The member lands on one of their own pages instead. + await expect(page.locator('[data-testid^="nav-"][aria-current="page"]')).toHaveCount(1); + } + }); +}); diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 0acd2290..70358c41 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -17,9 +17,10 @@ import { defineConfig, devices } from "@playwright/test"; const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080"; const STORAGE_STATE = "e2e/.auth/state.json"; -// The axe scan reads the DOM and the accessibility tree, which the engines build alike: one engine -// answers for all, so the other projects leave it out instead of tripling its time. -const CHROMIUM_ONLY = /a11y\.spec\.ts/; +// The axe scan reads the DOM and the accessibility tree, which the engines build alike, and the +// member spec checks which pages and requests a role gets, which no engine changes: one engine +// answers for all, so the other projects leave them out instead of tripling their time. +const CHROMIUM_ONLY = /(a11y|member)\.spec\.ts/; export default defineConfig({ testDir: "./e2e", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 548faf26..02d6f1c9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,7 +7,6 @@ import { api, setUnauthorizedHandler, type Me } from "./lib/api"; import { notifyActionError } from "./lib/actionErrors"; import { setLanguage, type LangCode } from "./i18n"; import { clearParams } from "./lib/urlState"; -import type { Page } from "./lib/urlState"; import { PREF_KEY, saveLayoutLocal, @@ -22,7 +21,7 @@ import { useVerifyEntry } from "./lib/useVerifyEntry"; import { useNavigation, useNavigationActions } from "./components/navigationContext"; import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext"; import { useFeedView, useFeedViewActions } from "./components/feedViewContext"; -import { moduleDef, moduleOrder } from "./lib/modules"; +import { moduleOrder } from "./lib/modules"; import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry"; import { lazyWithPreload } from "./lib/lazyWithPreload"; import { LoadingState } from "./components/ui/QueryState"; @@ -373,14 +372,11 @@ export default function App() { ? `yt:${ytSearch}` : `feed:${JSON.stringify({ ...filters, q: "" })}`; - // The module registry drives the render from `page` alone — no per-page chrome conditionals. A - // page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed, - // exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no - // side rail shows behind it. - const pageGate = moduleDef(page).gate; - const activePage: Page = !pageGate || pageGate(me!) ? page : "feed"; - const { Component: PageContent, maxWidth } = PAGE_CONTENT[activePage]; - const railDef = channelView ? undefined : PAGE_RAIL[activePage]; + // The module registry drives the render from `page` alone — no per-page chrome conditionals. A page + // the account can't reach never gets here: the navigation state already moved it to the feed + // (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it. + const { Component: PageContent, maxWidth } = PAGE_CONTENT[page]; + const railDef = channelView ? undefined : PAGE_RAIL[page]; return (
@@ -444,7 +440,7 @@ export default function App() { ) : ( } diff --git a/frontend/src/components/BootGate.dom.test.tsx b/frontend/src/components/BootGate.dom.test.tsx index e281c054..76452d45 100644 --- a/frontend/src/components/BootGate.dom.test.tsx +++ b/frontend/src/components/BootGate.dom.test.tsx @@ -47,7 +47,10 @@ function mount() { ); return qc; } -const account = (over: Partial = {}) => ({ id: 5, preferences: {}, ...over }) as Me; +// On a Plex-enabled instance: the remembered page these cases restore is the Plex module, which only +// an instance with Plex lets an account open. +const account = (over: Partial = {}) => + ({ id: 5, preferences: {}, plex_enabled: true, ...over }) as Me; let savePrefs: MockInstance; const retryButton = () => screen.findByRole("button", { name: i18n.t("common.retry") }); diff --git a/frontend/src/components/NavigationProvider.dom.test.tsx b/frontend/src/components/NavigationProvider.dom.test.tsx index 630c2a61..888eefb9 100644 --- a/frontend/src/components/NavigationProvider.dom.test.tsx +++ b/frontend/src/components/NavigationProvider.dom.test.tsx @@ -79,7 +79,8 @@ async function openOverlay() { beforeEach(() => { qc = new QueryClient(); - qc.setQueryData(qk.me(), { id: 1 }); + // An admin on a Plex-enabled instance: every module is open to it, so no gate moves a page here. + qc.setQueryData(qk.me(), { id: 1, role: "admin", plex_enabled: true }); localStorage.clear(); sessionStorage.setItem("siftlode.activeAccount", "1"); // account-scoped page memory window.history.replaceState({ sfPage: "feed" }, "", "/"); @@ -295,6 +296,43 @@ describe("NavigationProvider · Back", () => { }); }); +describe("NavigationProvider · a page the account cannot open", () => { + const member = { id: 1, role: "user", plex_enabled: true }; + + // A member following a link to an admin page saw the feed with no rail row current: the page + // stayed "scheduler" while only the content fell back. + it("lands on the feed, and the address bar says so", () => { + qc.setQueryData(qk.me(), member); + window.history.replaceState(null, "", "/?page=scheduler&keep=1"); + const { result } = render(); + expect(result.current.page).toBe("feed"); + expect(window.location.search).toBe("?keep=1"); + expect(window.history.state).toMatchObject({ sfPage: "feed" }); + }); + + it("lands on the feed when Back reaches an entry naming one", () => { + qc.setQueryData(qk.me(), member); + const { result } = render(); + act(() => result.current.setPage("files")); + act(() => { + window.history.replaceState({ sfPage: "config" }, "", "/?page=config"); + window.dispatchEvent(new PopStateEvent("popstate", { state: { sfPage: "config" } })); + }); + expect(result.current.page).toBe("feed"); + }); + + it("leaves the page it is on once the account loses it", async () => { + window.history.replaceState(null, "", "/?page=users"); + const { result } = render(); + expect(result.current.page).toBe("users"); + act(() => { + qc.setQueryData(qk.me(), member); + }); + await settle(); + expect(result.current.page).toBe("feed"); + }); +}); + describe("NavigationProvider · leaving a sub-view whose entry is the oldest of this document", () => { // A full tab can have dropped the entry under the one the channel page or the search pushed (see // lib/history's backStaysInDocument): leaving clears it in place instead of stepping out. diff --git a/frontend/src/components/NavigationProvider.tsx b/frontend/src/components/NavigationProvider.tsx index 8e61101f..6602fdfa 100644 --- a/frontend/src/components/NavigationProvider.tsx +++ b/frontend/src/components/NavigationProvider.tsx @@ -14,6 +14,7 @@ import { notifyNav, rememberNavPosition, } from "../lib/history"; +import { reachablePage } from "../lib/modules"; import { channelViewFrom, loadInitialPage, PAGE_KEY, type ChannelView } from "./navigationInit"; import { NavigationActionsContext, @@ -31,7 +32,12 @@ import { export function NavigationProvider({ children }: { children: ReactNode }) { const { t, i18n } = useTranslation(); - const [page, setPageState] = useState(loadInitialPage); + const [chosenPage, setPageState] = useState(loadInitialPage); + // The page on screen: the chosen one when this account may open it, else the feed — derived, so a + // link, a Back and a role taken away mid-session all land the same way, and the rail, the content + // and the address bar (stamped from it below) agree. + const me = useMe(); + const page = reachablePage(chosenPage, me); // Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search // spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides // in history.state and is free to re-fetch. The popstate handler derives it from history.state so @@ -171,7 +177,7 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // title, a playlist — `PageHeading` reports it), so a channel opened from a link is named the same // as one opened in the app. Signed out, the page on screen is the public landing whatever `page` // says — the tab read "What should I watch?" over the sign-in form (prod UAT 2026-09-19). - const signedIn = useMe() != null; + const signedIn = me != null; const objectTitle = useDocumentObjectTitle(); useEffect(() => { const parts = signedIn ? [objectTitle, t(pageTitleKey(page))] : []; diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts index ae4fb848..2e1d43de 100644 --- a/frontend/src/lib/modules.ts +++ b/frontend/src/lib/modules.ts @@ -183,3 +183,11 @@ export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map export function moduleOrder(me: Me): Page[] { return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id); } + +/** The page this account is shown for `page`: itself when its gate lets the account in, else the + * feed (a link to an admin page, a Back onto one, an admin role taken away mid-session). Signed + * out, no page renders, so nothing is moved. */ +export function reachablePage(page: Page, me: Me | null | undefined): Page { + const gate = moduleDef(page).gate; + return !me || !gate || gate(me) ? page : "feed"; +} diff --git a/scripts/e2e_seed.py b/scripts/e2e_seed.py index c1f6e470..5d545baf 100644 --- a/scripts/e2e_seed.py +++ b/scripts/e2e_seed.py @@ -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()