diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ef90f27..866a45b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,52 @@ jobs: - name: Lint, build, and test run: pnpm turbo run lint build test + e2e: + name: E2E + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10.30.1 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + + - uses: supabase/setup-cli@ab058987d8d6c725971f6cf9d0b5c98467e30bd1 # v1 + with: + version: 2.98.2 + + - name: Start local Supabase (db + REST only) + run: supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + + - name: Install Playwright Chromium + run: pnpm --filter @stremlist/e2e exec playwright install --with-deps chromium + + - name: Run E2E tests + run: pnpm --filter @stremlist/e2e test:e2e + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: apps/e2e/playwright-report + retention-days: 7 + version-bump: name: Auto version bump needs: ci diff --git a/.gitignore b/.gitignore index cac4628..1c0fdfa 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,7 @@ lerna-debug.log* !robots.txt # Supabase CLI local state supabase/.temp/ + +# Playwright +test-results/ +playwright-report/ diff --git a/apps/e2e/.prettierignore b/apps/e2e/.prettierignore new file mode 100644 index 0000000..f2459b4 --- /dev/null +++ b/apps/e2e/.prettierignore @@ -0,0 +1,2 @@ +playwright-report +test-results diff --git a/apps/e2e/README.md b/apps/e2e/README.md new file mode 100644 index 0000000..430e99e --- /dev/null +++ b/apps/e2e/README.md @@ -0,0 +1,64 @@ +# @stremlist/e2e + +End-to-end tests that exercise Stremlist the way a real user does: the addon +is installed into the **hosted Stremio Web app** (web.stremio.com) from a +backend running locally, with **live IMDb data** and a **local Supabase +stack**. The configure/onboarding pages of the frontend are covered too. + +## How it works + +- Playwright starts the backend (`:7301`) and the frontend (`:7302`) as web + servers with ports distinct from the dev ones, so tests can run next to a + normal dev session. +- The backend points at a local Supabase stack (`supabase start`), reset + between tests. Functional seeding goes through the backend's own HTTP API, + so tests exercise real code paths. +- Stremio Web runs in anonymous mode: each fresh browser context has its own + local addon collection. No Stremio account or shared state is involved. +- Chromium is launched with `--disable-features=LocalNetworkAccessChecks,...` + because Chrome otherwise blocks the HTTPS Stremio Web page from fetching the + addon on `127.0.0.1` (Local Network Access permission, never grantable in + headless runs). +- IMDb is live. Assertions are structural (ordering invariants, id shapes, + counts) or compare the Stremio UI against the addon's own catalog JSON from + the same run, so they do not depend on what is in the watchlist today. +- The default run and pull request CI execute all three projects: deterministic + local coverage, four live smoke tests, and the broader live regression suite. + +## Running locally + +```sh +# One-time / per boot: start the local Supabase stack (needs Docker running) +supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta + +# From the repo root: run every E2E project +pnpm test:e2e + +# Select one project while debugging +pnpm --filter @stremlist/e2e test:e2e --project=local +pnpm --filter @stremlist/e2e test:e2e --project=live-smoke +pnpm --filter @stremlist/e2e test:e2e --project=live-regression +``` + +The suite deletes test users between cases. Foreign-key cascades clear their +watchlists and caches in the same database statement. The harness rejects any +non-loopback Supabase URL unless the caller provides the explicit destructive +confirmation described below. + +## Environment knobs + +| Variable | Purpose | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `E2E_SUPABASE_URL` / `E2E_SUPABASE_SERVICE_ROLE_KEY` | Non-default local Supabase stack | +| `E2E_ALLOW_REMOTE_DATABASE=I_UNDERSTAND_THIS_WIPES_DATA` | Permit an isolated remote test project. Cleanup deletes every user and all dependent data | +| `E2E_IMDB_USER_ID` / `E2E_IMDB_USER_ID_2` | Override the public watchlists under test | +| `E2E_IMDB_LIST_ID` | Override the public `ls` list under test | +| `E2E_PRIVATE_IMDB_USER_ID` | Override the private watchlist under test | +| `E2E_PRIVATE_IMDB_LIST_ID` | Enable the private `ls` list test | + +## Known limitations + +- Drag-and-drop catalog reordering (pointer-based dnd-kit) is not covered. +- The newsletter endpoint is not covered (it would email real people). +- The live smoke and regression suites depend on web.stremio.com and IMDb. CI + retries failures twice. diff --git a/apps/e2e/env.ts b/apps/e2e/env.ts new file mode 100644 index 0000000..fcbec1d --- /dev/null +++ b/apps/e2e/env.ts @@ -0,0 +1,52 @@ +// Shared constants for the E2E harness. Every port is distinct from the +// regular dev ports (7001/5173) so tests can run next to a dev session. + +export const BACKEND_PORT = 7301; +export const FRONTEND_PORT = 7302; + +export const BACKEND_URL = `http://127.0.0.1:${BACKEND_PORT}`; +export const FRONTEND_URL = `http://127.0.0.1:${FRONTEND_PORT}`; + +export const STREMIO_WEB_URL = "https://web.stremio.com"; + +// Local Supabase stack (supabase start). The service-role key below is the +// public, well-known key every local Supabase CLI stack ships with — it is not +// a secret. Both values can be overridden for non-default stacks. +export const SUPABASE_URL = + process.env.E2E_SUPABASE_URL ?? "http://127.0.0.1:54321"; +export const SUPABASE_SERVICE_ROLE_KEY = + process.env.E2E_SUPABASE_SERVICE_ROLE_KEY ?? + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU"; + +const REMOTE_DATABASE_CONFIRMATION = "I_UNDERSTAND_THIS_WIPES_DATA"; + +function assertSafeSupabaseTarget(): void { + let hostname: string; + try { + hostname = new URL(SUPABASE_URL).hostname.toLowerCase(); + } catch { + throw new Error(`E2E_SUPABASE_URL is not a valid URL: ${SUPABASE_URL}`); + } + + const isLoopback = ["localhost", "127.0.0.1", "[::1]", "::1"].includes( + hostname, + ); + const remoteWipeConfirmed = + process.env.E2E_ALLOW_REMOTE_DATABASE === REMOTE_DATABASE_CONFIRMATION; + + if (!isLoopback && !remoteWipeConfirmed) { + throw new Error( + `Refusing to run destructive E2E cleanup against non-loopback Supabase host "${hostname}". ` + + `Use a disposable local stack, or set E2E_ALLOW_REMOTE_DATABASE=${REMOTE_DATABASE_CONFIRMATION} only for an isolated remote test project.`, + ); + } +} + +assertSafeSupabaseTarget(); + +// Short cooldown so refresh-throttle tests stay fast. +export const REFRESH_COOLDOWN_SECONDS = 2; + +export function addonManifestUrl(userId: string): string { + return `${BACKEND_URL}/${userId}/manifest.json`; +} diff --git a/apps/e2e/eslint.config.mjs b/apps/e2e/eslint.config.mjs new file mode 100644 index 0000000..b971edd --- /dev/null +++ b/apps/e2e/eslint.config.mjs @@ -0,0 +1,19 @@ +import baseConfig from "@stremlist/eslint-config/base"; +import prettier from "eslint-config-prettier/flat"; + +export default [ + { ignores: ["eslint.config.mjs", "playwright-report/**", "test-results/**"] }, + ...baseConfig, + { + languageOptions: { + parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + curly: ["error", "multi-line"], + }, + }, + prettier, +]; diff --git a/apps/e2e/helpers/api.ts b/apps/e2e/helpers/api.ts new file mode 100644 index 0000000..08c13cb --- /dev/null +++ b/apps/e2e/helpers/api.ts @@ -0,0 +1,117 @@ +import type { + StremioManifest, + StremioMeta, + UserConfigResponse, + UserConfigUpdateWatchlist, +} from "@stremlist/shared"; +import { hcWithType } from "@stremlist/backend/client"; +import { BACKEND_URL } from "../env.js"; + +// Thin typed wrappers over the backend HTTP API. Tests use these both to +// arrange state (through real code paths) and to assert the addon protocol +// contract that Stremio clients consume. + +export type CatalogMeta = StremioMeta; +type Manifest = StremioManifest; +type UserConfig = UserConfigResponse; +const api = hcWithType(BACKEND_URL); + +async function getJson(path: string): Promise<{ status: number; body: T }> { + const response = await fetch(`${BACKEND_URL}${path}`); + return { status: response.status, body: (await response.json()) as T }; +} + +export async function getBaseManifest(): Promise { + return (await getJson("/manifest.json")).body; +} + +export async function getUserManifest(userId: string): Promise { + return (await getJson(`/${userId}/manifest.json`)).body; +} + +export async function getConfig( + userId: string, +): Promise<{ status: number; body: UserConfig }> { + const response = await api[":userId"].config.$get({ param: { userId } }); + return { + status: response.status, + body: (await response.json()) as UserConfig, + }; +} + +type ConfigWatchlistInput = UserConfigUpdateWatchlist; + +export async function postConfig( + userId: string, + watchlists: ConfigWatchlistInput[], + rpdbApiKey?: string, +): Promise<{ status: number; body: unknown }> { + const response = await api[":userId"].config.$post({ + param: { userId }, + json: { watchlists, rpdbApiKey }, + }); + return { status: response.status, body: await response.json() }; +} + +export async function getCatalog( + userId: string, + type: string, + catalogId: string, +): Promise<{ status: number; metas: CatalogMeta[] }> { + const { status, body } = await getJson<{ metas: CatalogMeta[] }>( + `/${userId}/catalog/${type}/${catalogId}.json`, + ); + return { status, metas: body.metas }; +} + +export async function getMeta( + userId: string, + type: string, + id: string, +): Promise<{ status: number; meta: CatalogMeta | null }> { + const { status, body } = await getJson<{ meta: CatalogMeta | null }>( + `/${userId}/meta/${type}/${id}.json`, + ); + return { status, meta: body.meta }; +} + +export async function refresh( + userId: string, +): Promise<{ status: number; body: Record }> { + const response = await api[":userId"].refresh.$post({ + param: { userId }, + }); + return { + status: response.status, + body: (await response.json()) as Record, + }; +} + +export async function validateUser( + userId: string, +): Promise> { + const response = await api.validate[":userId"].$get({ param: { userId } }); + return (await response.json()) as Record; +} + +export async function validateList( + listId: string, +): Promise> { + const response = await api["validate-list"][":listId"].$get({ + param: { listId }, + }); + return (await response.json()) as Record; +} + +/** + * Bootstrap a user exactly the way a real install does: the first manifest + * fetch upserts the user and seeds the default watchlist. Returns the config. + */ +export async function bootstrapUser(userId: string): Promise { + await getUserManifest(userId); + const { status, body } = await getConfig(userId); + if (status !== 200) { + throw new Error(`bootstrapUser(${userId}) got ${status}`); + } + return body; +} diff --git a/apps/e2e/helpers/db.ts b/apps/e2e/helpers/db.ts new file mode 100644 index 0000000..54e6214 --- /dev/null +++ b/apps/e2e/helpers/db.ts @@ -0,0 +1,37 @@ +import { createClient } from "@supabase/supabase-js"; +import type { Database } from "@stremlist/shared"; +import { SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL } from "../env.js"; +import { E2E_USER_IDS } from "./test-data.js"; + +// Service-role client: bypasses RLS, used only to reset and inspect state +// between tests. All functional seeding goes through the backend's own HTTP +// API so the tests exercise real code paths. +const db = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY); + +/** Delete only this run's test users. Foreign-key cascades reset their data. */ +export async function resetDb(): Promise { + const { error } = await db + .from("users") + .delete() + .in("imdb_user_id", [...E2E_USER_IDS]); + if (error) throw new Error(`resetDb failed: ${error.message}`); +} + +/** Rewind a user's last_fetched_at so the refresh cooldown does not apply. */ +export async function clearRefreshCooldown(userId: string): Promise { + const past = new Date(Date.now() - 60 * 60 * 1000).toISOString(); + const { error } = await db + .from("users") + .update({ last_fetched_at: past }) + .eq("imdb_user_id", userId); + if (error) throw new Error(`clearRefreshCooldown failed: ${error.message}`); +} + +export async function countCacheItems(watchlistId: string): Promise { + const { count, error } = await db + .from("watchlist_cache_items") + .select("*", { count: "exact", head: true }) + .eq("watchlist_id", watchlistId); + if (error) throw new Error(`countCacheItems failed: ${error.message}`); + return count ?? 0; +} diff --git a/apps/e2e/helpers/stremio.ts b/apps/e2e/helpers/stremio.ts new file mode 100644 index 0000000..dd100aa --- /dev/null +++ b/apps/e2e/helpers/stremio.ts @@ -0,0 +1,72 @@ +import type { Page } from "@playwright/test"; +import { expect } from "@playwright/test"; +import { STREMIO_WEB_URL } from "../env.js"; + +// Page helpers for the hosted Stremio Web app (web.stremio.com). The app runs +// in anonymous/local mode: a fresh browser context has no account and stores +// the addon collection in localStorage, so tests are fully isolated. + +export function addonsDeepLink(manifestUrl: string): string { + return `${STREMIO_WEB_URL}/#/addons?addon=${encodeURIComponent(manifestUrl)}`; +} + +export function discoverUrl( + manifestUrl: string, + type: "movie" | "series", + catalogId: string, +): string { + return `${STREMIO_WEB_URL}/#/discover/${encodeURIComponent(manifestUrl)}/${type}/${encodeURIComponent(catalogId)}`; +} + +/** + * Dismiss the "install the desktop app" prompt if it is showing. Its "Install" + * link would otherwise collide with the addon modal's Install button. + */ +export async function dismissDesktopAppPrompt(page: Page): Promise { + const dismiss = page.getByText("Don't show again", { exact: true }); + try { + await dismiss.click({ timeout: 3_000 }); + } catch { + // Prompt not shown — nothing to do. + } +} + +/** + * Open the addon deep link and complete the install through the modal. + * Resolves once the modal is gone and the addon shows as installed. + */ +export async function installAddon( + page: Page, + manifestUrl: string, +): Promise { + await page.goto(addonsDeepLink(manifestUrl)); + await dismissDesktopAppPrompt(page); + const installButton = page.getByText("Install", { exact: true }).last(); + await expect(installButton).toBeVisible(); + await installButton.click(); + // The modal closes on success; "Uninstall"/"Configure" appear on the card. + await expect( + page.getByText("Stremlist", { exact: true }).first(), + ).toBeVisible(); +} + +/** Uninstall the addon from the Addons page, confirming in the modal. */ +export async function uninstallAddon( + page: Page, + manifestUrl: string, +): Promise { + await page.goto(addonsDeepLink(manifestUrl)); + await dismissDesktopAppPrompt(page); + const uninstallButton = page.getByText("Uninstall", { exact: true }).last(); + await expect(uninstallButton).toBeVisible(); + await uninstallButton.click(); +} + +/** Titles of the meta items currently rendered on a Discover page, in order. */ +export async function discoverItemTitles(page: Page): Promise { + const links = page.locator('a[href^="#/detail/"][title]'); + await expect(links.first()).toBeVisible(); + return links.evaluateAll((elements) => + elements.map((el) => el.getAttribute("title") ?? ""), + ); +} diff --git a/apps/e2e/helpers/test-data.ts b/apps/e2e/helpers/test-data.ts new file mode 100644 index 0000000..db6e9df --- /dev/null +++ b/apps/e2e/helpers/test-data.ts @@ -0,0 +1,41 @@ +// Real IMDb ids used by the E2E suite. Tests hit live IMDb through the +// backend, so assertions stay structural (ordering invariants, counts, id +// shapes) instead of pinning exact titles that could change over time. + +// Small public watchlist (~19 items) — one of the ids the backend stress test +// already exercises, kept small so live fetches stay fast. +export const PUBLIC_USER = process.env.E2E_IMDB_USER_ID ?? "ur102135398"; + +// Second public watchlist for multi-catalog scenarios. +export const PUBLIC_USER_2 = process.env.E2E_IMDB_USER_ID_2 ?? "ur102551738"; + +// Long-standing public IMDb list ("Top 100 Greatest Movies of All Time"). +export const PUBLIC_LIST = process.env.E2E_IMDB_LIST_ID ?? "ls055592025"; + +// Syntactically valid ids that do not exist. IMDb user ids are ~9 digits; +// a 13-digit id is far outside the allocated range. +export const UNKNOWN_USER = "ur9999999999999"; +export const UNKNOWN_LIST = "ls9999999999999"; + +// Account whose watchlist is deliberately kept private for these tests +// (maintainer-owned). The p-handle resolves to the same account, covering the +// handle-resolution path for private sources too. +export const PRIVATE_USER = + process.env.E2E_PRIVATE_IMDB_USER_ID ?? "ur198342247"; +export const PRIVATE_P_HANDLE = "p.e4ialbfdp3rntdahbslk5yzovm"; + +// Cleanup must stay scoped to identities explicitly owned by this E2E run. +// The local Supabase stack may share a persisted volume with development. +export const E2E_USER_IDS = [ + PUBLIC_USER, + PUBLIC_USER_2, + UNKNOWN_USER, + PRIVATE_USER, +] as const; + +// No stable private ls list is available; provide one via env to enable the +// private-list test. +export const PRIVATE_LIST = process.env.E2E_PRIVATE_IMDB_LIST_ID; + +// p-handle that must resolve to a canonical ur id (IMDb founder's profile). +export const P_HANDLE = "p.colneedham"; diff --git a/apps/e2e/package.json b/apps/e2e/package.json new file mode 100644 index 0000000..6ca7054 --- /dev/null +++ b/apps/e2e/package.json @@ -0,0 +1,26 @@ +{ + "name": "@stremlist/e2e", + "private": true, + "type": "module", + "scripts": { + "test:e2e": "tsx preflight.ts && playwright test", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "format": "prettier . --write", + "format:check": "prettier . --check" + }, + "devDependencies": { + "@playwright/test": "^1.62.1", + "@stremlist/backend": "workspace:*", + "@stremlist/eslint-config": "workspace:*", + "@stremlist/frontend": "workspace:*", + "@stremlist/shared": "workspace:*", + "@supabase/supabase-js": "^2.95.3", + "@types/node": "^20.11.17", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "prettier": "^3.8.1", + "tsx": "^4.7.1", + "typescript": "^5.8.3" + } +} diff --git a/apps/e2e/playwright.config.ts b/apps/e2e/playwright.config.ts new file mode 100644 index 0000000..36bccb6 --- /dev/null +++ b/apps/e2e/playwright.config.ts @@ -0,0 +1,88 @@ +import { defineConfig, devices } from "@playwright/test"; +import { + BACKEND_PORT, + BACKEND_URL, + FRONTEND_PORT, + FRONTEND_URL, + REFRESH_COOLDOWN_SECONDS, + SUPABASE_SERVICE_ROLE_KEY, + SUPABASE_URL, +} from "./env.js"; + +export default defineConfig({ + testDir: "./tests", + // Every project shares one backend and database, so tests run serially. + fullyParallel: false, + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + timeout: 120_000, + expect: { timeout: 20_000 }, + reporter: process.env.CI + ? [["list"], ["html", { open: "never" }], ["github"]] + : [["list"], ["html", { open: "never" }]], + use: { + trace: "retain-on-failure", + screenshot: "only-on-failure", + // stremio-web registers a service worker; blocking it keeps network + // behavior deterministic across fresh contexts. + serviceWorkers: "block", + launchOptions: { + args: [ + // Chrome blocks fetches from public HTTPS pages (web.stremio.com) to + // loopback addresses behind the Local Network Access permission, which + // headless runs can never grant. Older Chromium versions gate the same + // thing behind Private Network Access preflights. Disable both so the + // hosted Stremio Web app can talk to the local addon under test. + "--disable-features=LocalNetworkAccessChecks,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessSendPreflights,PrivateNetworkAccessRespectPreflightResults", + ], + }, + }, + projects: [ + { + name: "local", + grep: /@local/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "live-smoke", + grep: /@live-smoke/, + use: { ...devices["Desktop Chrome"] }, + }, + { + name: "live-regression", + grep: /@live-regression/, + use: { ...devices["Desktop Chrome"] }, + }, + ], + webServer: [ + { + command: "pnpm exec tsx src/dev.ts", + cwd: "../backend", + url: `${BACKEND_URL}/health`, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + env: { + PORT: String(BACKEND_PORT), + SUPABASE_URL, + SUPABASE_SERVICE_ROLE_KEY, + FRONTEND_URL, + REFRESH_COOLDOWN_SECONDS: String(REFRESH_COOLDOWN_SECONDS), + // The Resend SDK throws at import time without a key. Newsletter + // delivery is deliberately out of E2E scope (it would email real + // people), so a dummy key is enough to boot the app. + RESEND_API_KEY: "re_e2e_dummy_key", + }, + }, + { + command: `pnpm exec vite --port ${FRONTEND_PORT} --strictPort`, + cwd: "../frontend", + url: FRONTEND_URL, + reuseExistingServer: !process.env.CI, + timeout: 60_000, + env: { + VITE_BACKEND_URL: BACKEND_URL, + }, + }, + ], +}); diff --git a/apps/e2e/preflight.ts b/apps/e2e/preflight.ts new file mode 100644 index 0000000..ae9e447 --- /dev/null +++ b/apps/e2e/preflight.ts @@ -0,0 +1,27 @@ +import { SUPABASE_SERVICE_ROLE_KEY, SUPABASE_URL } from "./env.js"; + +// This script runs before Playwright starts its web servers, so database +// failures surface immediately instead of becoming a backend startup timeout. +try { + const response = await fetch( + `${SUPABASE_URL}/rest/v1/users?select=imdb_user_id&limit=1`, + { + headers: { + apikey: SUPABASE_SERVICE_ROLE_KEY, + Authorization: `Bearer ${SUPABASE_SERVICE_ROLE_KEY}`, + }, + }, + ); + if (!response.ok) { + throw new Error( + `Supabase responded ${response.status}: ${await response.text()}`, + ); + } +} catch (error) { + throw new Error( + `E2E Supabase stack is not reachable at ${SUPABASE_URL}.\n` + + `Start the local stack from the repository root with:\n` + + ` supabase start -x gotrue,realtime,storage-api,imgproxy,studio,edge-runtime,logflare,vector,supavisor,mailpit,postgres-meta\n` + + `Underlying error: ${error instanceof Error ? error.message : String(error)}`, + ); +} diff --git a/apps/e2e/tests/addon-api.spec.ts b/apps/e2e/tests/addon-api.spec.ts new file mode 100644 index 0000000..f0e0a9e --- /dev/null +++ b/apps/e2e/tests/addon-api.spec.ts @@ -0,0 +1,469 @@ +import { expect, test } from "@playwright/test"; +import { FRONTEND_URL, BACKEND_URL } from "../env.js"; +import { + bootstrapUser, + getBaseManifest, + getCatalog, + getConfig, + getMeta, + getUserManifest, + postConfig, + refresh, + validateList, + validateUser, + type CatalogMeta, +} from "../helpers/api.js"; +import { + clearRefreshCooldown, + countCacheItems, + resetDb, +} from "../helpers/db.js"; +import { + P_HANDLE, + PRIVATE_LIST, + PRIVATE_P_HANDLE, + PRIVATE_USER, + PUBLIC_LIST, + PUBLIC_USER, + UNKNOWN_LIST, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// Addon protocol contract — the exact HTTP surface every Stremio client +// (web, desktop, mobile) consumes. Data comes from live IMDb, so assertions +// are structural: ordering invariants, id shapes, counts. + +const CATALOG_ID_PATTERN = + /^wl-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}-(movie|series)$/; + +test.beforeEach(async () => { + await resetDb(); +}); + +test.describe("manifest", () => { + test("base manifest requires configuration", { tag: "@local" }, async () => { + const manifest = await getBaseManifest(); + expect(manifest.behaviorHints?.configurationRequired).toBe(true); + expect(manifest.behaviorHints?.configurable).toBe(true); + expect(manifest.name).toBe("Stremlist"); + }); + + test( + "first user manifest bootstraps the install", + { tag: "@local" }, + async () => { + const manifest = await getUserManifest(PUBLIC_USER); + expect(manifest.id).toBe(`com.stremlist.${PUBLIC_USER}`); + expect(manifest.behaviorHints?.configurationRequired).toBe(false); + // Default watchlist in split mode → one movie + one series catalog. + expect(manifest.catalogs).toHaveLength(2); + expect(manifest.catalogs.map((c) => c.type).sort()).toEqual([ + "movie", + "series", + ]); + for (const catalogRef of manifest.catalogs) { + expect(catalogRef.id).toMatch(CATALOG_ID_PATTERN); + expect(catalogRef.name).toContain("Stremlist"); + } + // The meta resource must stay declared — Stremio clients rely on it. + const metaResource = manifest.resources.find( + (r) => + typeof r === "object" && + r !== null && + (r as { name?: string }).name === "meta", + ) as { idPrefixes?: string[] } | undefined; + expect(metaResource?.idPrefixes).toEqual(["tt"]); + }, + ); + + test( + "display mode controls emitted catalogs", + { tag: "@local" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + await postConfig(PUBLIC_USER, [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "added_at-asc", + displayMode: "movie", + }, + ]); + const manifest = await getUserManifest(PUBLIC_USER); + expect(manifest.catalogs).toHaveLength(1); + expect(manifest.catalogs[0].type).toBe("movie"); + }, + ); +}); + +test.describe("catalogs", () => { + test( + "movie catalog serves the live IMDb watchlist", + { tag: "@live-smoke" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const { status, metas } = await getCatalog( + PUBLIC_USER, + "movie", + catalogId, + ); + expect(status).toBe(200); + expect(metas.length).toBeGreaterThan(0); + for (const meta of metas) { + expect(meta.type).toBe("movie"); + expect(meta.id).toMatch(/^tt\d+$/); + expect(meta.name.length).toBeGreaterThan(0); + } + // The fetch must have populated the per-item cache. + expect(await countCacheItems(config.watchlists[0].id)).toBeGreaterThan(0); + }, + ); + + test( + "every sort option orders the catalog correctly", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + + const setSort = async (sortOption: string) => { + const { status } = await postConfig(PUBLIC_USER, [ + { id: watchlist.id, imdbUserId: watchlist.imdbUserId, sortOption }, + ]); + expect(status).toBe(200); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + return metas; + }; + + const baseline = await setSort("added_at-asc"); + expect(baseline.length).toBeGreaterThan(1); + const ids = (metas: CatalogMeta[]) => metas.map((m) => m.id); + const years = (metas: CatalogMeta[]) => + metas.map((m) => parseInt(m.releaseInfo ?? "0", 10) || 0); + const ratings = (metas: CatalogMeta[]) => + metas.map((m) => parseFloat(m.imdbRating ?? "0") || 0); + const expectMonotonic = (values: number[], direction: "asc" | "desc") => { + for (let i = 1; i < values.length; i++) { + if (direction === "asc") + expect(values[i]).toBeGreaterThanOrEqual(values[i - 1]); + else expect(values[i]).toBeLessThanOrEqual(values[i - 1]); + } + }; + + const addedDesc = await setSort("added_at-desc"); + expect(ids(addedDesc)).toEqual([...ids(baseline)].reverse()); + + const titleAsc = await setSort("title-asc"); + const namesAsc = titleAsc.map((m) => m.name); + expect(namesAsc).toEqual( + [...namesAsc].sort((a, b) => a.localeCompare(b)), + ); + + const titleDesc = await setSort("title-desc"); + const namesDesc = titleDesc.map((m) => m.name); + expect(namesDesc).toEqual( + [...namesDesc].sort((a, b) => b.localeCompare(a)), + ); + + expectMonotonic(years(await setSort("year-asc")), "asc"); + expectMonotonic(years(await setSort("year-desc")), "desc"); + expectMonotonic(ratings(await setSort("rating-asc")), "asc"); + expectMonotonic(ratings(await setSort("rating-desc")), "desc"); + + const random = await setSort("random"); + expect(ids(random).sort()).toEqual(ids(baseline).sort()); + }, + ); + + test( + "ls list source serves a public IMDb list", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + await postConfig(PUBLIC_USER, [ + { imdbUserId: PUBLIC_LIST, sortOption: "added_at-asc" }, + ]); + const updated = await getConfig(PUBLIC_USER); + const watchlist = updated.body.watchlists[0]; + expect(watchlist.imdbUserId).toBe(PUBLIC_LIST); + const { metas } = await getCatalog( + PUBLIC_USER, + "movie", + `wl-${watchlist.id}-movie`, + ); + expect(metas.length).toBeGreaterThan(0); + expect(config.watchlists[0].id).not.toBe(watchlist.id); + }, + ); + + test( + "built-in chart catalog serves live chart data", + { tag: "@live-regression" }, + async () => { + await bootstrapUser(PUBLIC_USER); + await postConfig(PUBLIC_USER, [ + { + imdbUserId: "imdb:top-rated-movies", + sortOption: "added_at-asc", + displayMode: "movie", + }, + ]); + const { body } = await getConfig(PUBLIC_USER); + const chart = body.watchlists[0]; + const { metas } = await getCatalog( + PUBLIC_USER, + "movie", + `wl-${chart.id}-movie`, + ); + // IMDb Top 250 — allow slack for titles Stremio types can't represent. + expect(metas.length).toBeGreaterThan(200); + for (const meta of metas.slice(0, 10)) { + expect(meta.type).toBe("movie"); + expect(meta.id).toMatch(/^tt\d+$/); + } + }, + ); + + test("RPDB key rewrites posters", { tag: "@live-regression" }, async () => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + await postConfig( + PUBLIC_USER, + [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "added_at-asc", + }, + ], + "e2e-test-key", + ); + const { metas } = await getCatalog( + PUBLIC_USER, + "movie", + `wl-${watchlist.id}-movie`, + ); + expect(metas.length).toBeGreaterThan(0); + for (const meta of metas) { + expect(meta.poster).toContain( + "https://api.ratingposterdb.com/e2e-test-key/imdb/poster-default/", + ); + } + }); + + test( + "unknown watchlist degrades to an informational card, not a 500", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(UNKNOWN_USER); + const { status, metas } = await getCatalog( + UNKNOWN_USER, + "movie", + `wl-${config.watchlists[0].id}-movie`, + ); + expect(status).toBe(200); + expect(metas).toHaveLength(1); + expect(metas[0].id).toBe("stremlist:unavailable:not_found"); + expect(metas[0].name).toContain("not found"); + }, + ); + + test( + "private watchlist degrades to an informational card", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PRIVATE_USER); + const { status, metas } = await getCatalog( + PRIVATE_USER, + "movie", + `wl-${config.watchlists[0].id}-movie`, + ); + expect(status).toBe(200); + expect(metas).toHaveLength(1); + expect(metas[0].id).toBe("stremlist:unavailable:private"); + expect(metas[0].name).toContain("private"); + }, + ); + + test( + "malformed catalog requests return empty catalogs", + { tag: "@local" }, + async () => { + await bootstrapUser(PUBLIC_USER); + const unknownCatalog = await getCatalog( + PUBLIC_USER, + "movie", + "wl-00000000-0000-4000-8000-000000000000-movie", + ); + expect(unknownCatalog.status).toBe(200); + expect(unknownCatalog.metas).toEqual([]); + + const badType = await getCatalog( + PUBLIC_USER, + "channel", + "stremlist-movies", + ); + expect(badType.status).toBe(200); + expect(badType.metas).toEqual([]); + }, + ); +}); + +test.describe("meta", () => { + test( + "serves cached meta and falls back to null on misses", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + const first = metas[0]; + + const hit = await getMeta(PUBLIC_USER, "movie", first.id); + expect(hit.status).toBe(200); + expect(hit.meta?.name).toBe(first.name); + expect(hit.meta?.id).toBe(first.id); + + // Cache-only: unknown ids must return null (Stremio then asks Cinemeta), + // never 500. + const miss = await getMeta(PUBLIC_USER, "movie", "tt9999999999"); + expect(miss.status).toBe(200); + expect(miss.meta).toBeNull(); + }, + ); +}); + +test.describe("validation endpoints", () => { + test( + "validates public, unknown, and p-handle sources", + { tag: "@live-regression" }, + async () => { + expect(await validateUser(PUBLIC_USER)).toEqual({ + valid: true, + userId: PUBLIC_USER, + }); + expect(await validateUser(UNKNOWN_USER)).toEqual({ + valid: false, + reason: "not_found", + }); + expect(await validateList(PUBLIC_LIST)).toEqual({ valid: true }); + expect(await validateList(UNKNOWN_LIST)).toEqual({ + valid: false, + reason: "not_found", + }); + + // p-handles resolve to a canonical ur id first. The target account's + // watchlist visibility is not under our control, so only assert shape. + const handleResult = await validateUser(P_HANDLE); + if (handleResult.valid) { + expect(String(handleResult.userId)).toMatch(/^ur\d+$/); + } else { + expect(["private", "not_found"]).toContain(handleResult.reason); + } + }, + ); + + test("reports private sources", { tag: "@live-regression" }, async () => { + expect(await validateUser(PRIVATE_USER)).toEqual({ + valid: false, + reason: "private", + }); + // Same account via its p-handle: exercises handle resolution on a + // private source. + expect(await validateUser(PRIVATE_P_HANDLE)).toEqual({ + valid: false, + reason: "private", + }); + if (PRIVATE_LIST) { + expect(await validateList(PRIVATE_LIST)).toEqual({ + valid: false, + reason: "private", + }); + } + }); +}); + +test.describe("config API", () => { + test("rejects invalid configurations", { tag: "@local" }, async () => { + await bootstrapUser(PUBLIC_USER); + const valid = { imdbUserId: PUBLIC_USER, sortOption: "added_at-asc" }; + + expect((await postConfig(PUBLIC_USER, [])).status).toBe(400); + expect( + ( + await postConfig( + PUBLIC_USER, + Array.from({ length: 11 }, () => valid), + ) + ).status, + ).toBe(400); + expect( + ( + await postConfig(PUBLIC_USER, [ + { ...valid, catalogTitle: "x".repeat(31) }, + ]) + ).status, + ).toBe(400); + expect( + (await postConfig(PUBLIC_USER, [{ ...valid, sortOption: "bogus" }])) + .status, + ).toBe(400); + expect((await postConfig(PUBLIC_USER, [valid, valid])).status).toBe(400); + expect( + (await postConfig(PUBLIC_USER, [{ ...valid, imdbUserId: "banana" }])) + .status, + ).toBe(400); + }); + + test("404s for users that never installed", { tag: "@local" }, async () => { + const { status } = await getConfig(PUBLIC_USER); + expect(status).toBe(404); + }); +}); + +test.describe("refresh", () => { + test( + "refreshes from live IMDb and then throttles", + { tag: "@live-regression" }, + async () => { + const config = await bootstrapUser(PUBLIC_USER); + await clearRefreshCooldown(PUBLIC_USER); + + const first = await refresh(PUBLIC_USER); + expect(first.status).toBe(200); + expect(first.body.ok).toBe(true); + expect(first.body.refreshed).toBe(1); + expect(first.body.failed).toBe(0); + expect(first.body.total).toBe(1); + expect(await countCacheItems(config.watchlists[0].id)).toBeGreaterThan(0); + + const second = await refresh(PUBLIC_USER); + expect(second.body.throttled).toBe(true); + }, + ); +}); + +test.describe("misc endpoints", () => { + test("health, stats, and configure redirect", { tag: "@local" }, async () => { + const health = await fetch(`${BACKEND_URL}/health`); + expect(health.status).toBe(200); + expect(((await health.json()) as { database: string }).database).toBe("up"); + + await bootstrapUser(PUBLIC_USER); + const stats = await fetch(`${BACKEND_URL}/stats`); + expect( + ((await stats.json()) as { activeUsers: number }).activeUsers, + ).toBeGreaterThanOrEqual(1); + + const configure = await fetch(`${BACKEND_URL}/${PUBLIC_USER}/configure`, { + redirect: "manual", + }); + expect(configure.status).toBe(302); + expect(configure.headers.get("location")).toBe( + `${FRONTEND_URL}/configure?userId=${PUBLIC_USER}`, + ); + }); +}); diff --git a/apps/e2e/tests/configure-page.spec.ts b/apps/e2e/tests/configure-page.spec.ts new file mode 100644 index 0000000..5f5e209 --- /dev/null +++ b/apps/e2e/tests/configure-page.spec.ts @@ -0,0 +1,170 @@ +import { expect, test } from "@playwright/test"; +import { FRONTEND_URL } from "../env.js"; +import { bootstrapUser, getConfig } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + PUBLIC_LIST, + PUBLIC_USER, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// The /configure page: catalog management, options, refresh, install links. + +const configureUrl = (userId: string) => + `${FRONTEND_URL}/configure?userId=${userId}`; + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "loads the existing configuration", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + + await expect(page.getByText("Catalog 1")).toBeVisible(); + await expect( + page.locator(`input[value="${PUBLIC_USER}"]`).first(), + ).toBeVisible(); + await expect( + page.getByRole("link", { name: "Open in Stremio Web" }), + ).toBeVisible(); + }, +); + +test( + "adds an ls list catalog and saves", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + await page.getByRole("button", { name: "Add Catalog" }).click(); + await expect(page.getByText("Catalog 2")).toBeVisible(); + + const idInputs = page.locator('input[placeholder*="ur12345678"]'); + await idInputs.last().fill(PUBLIC_LIST); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + + const { body } = await getConfig(PUBLIC_USER); + expect(body.watchlists).toHaveLength(2); + expect(body.watchlists.map((w) => w.imdbUserId)).toContain(PUBLIC_LIST); + }, +); + +test("adds a built-in chart catalog", { tag: "@local" }, async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + await page.getByRole("button", { name: "Add Built-in Catalog" }).click(); + await page.getByRole("menuitem", { name: "Top 250 Movies" }).click(); + await expect(page.getByText("Built-in", { exact: true })).toBeVisible(); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + + const { body } = await getConfig(PUBLIC_USER); + expect(body.watchlists.map((w) => w.imdbUserId)).toContain( + "imdb:top-rated-movies", + ); +}); + +test( + "changes sort order and content filter", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + // Radix selects: one combobox for "Sort Order", one for "Show", in DOM order. + await page.getByRole("combobox").nth(0).click(); + await page.getByRole("option", { name: "Highest Rated" }).click(); + await page.getByRole("combobox").nth(1).click(); + await page.getByRole("option", { name: "Movies only" }).click(); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + + const { body } = await getConfig(PUBLIC_USER); + expect(body.watchlists[0].sortOption).toBe("rating-desc"); + expect(body.watchlists[0].displayMode).toBe("movie"); + }, +); + +test("saves and clears the RPDB key", { tag: "@local" }, async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + await page.locator("#rpdb-api-key").fill("e2e-rpdb-key"); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + expect((await getConfig(PUBLIC_USER)).body.rpdbApiKey).toBe("e2e-rpdb-key"); + + await page.locator("#rpdb-api-key").fill(""); + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + expect((await getConfig(PUBLIC_USER)).body.rpdbApiKey).toBeNull(); +}); + +test("removes a catalog", { tag: "@local" }, async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await page.getByRole("button", { name: "Add Built-in Catalog" }).click(); + await page.getByRole("menuitem", { name: "Box Office (Weekend)" }).click(); + await expect(page.getByText("Catalog 2")).toBeVisible(); + + await page.getByLabel("Remove catalog").last().click(); + await expect(page.getByText("Catalog 2")).not.toBeVisible(); + + await page.getByRole("button", { name: "Save", exact: true }).click(); + await expect(page.getByText("Saved!", { exact: false })).toBeVisible(); + expect((await getConfig(PUBLIC_USER)).body.watchlists).toHaveLength(1); +}); + +test( + "manual refresh hits the backend and starts the cooldown", + { tag: "@live-regression" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(configureUrl(PUBLIC_USER)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + + const refreshResponse = page.waitForResponse( + (response) => + response.url().includes("/refresh") && + response.request().method() === "POST", + ); + await page.getByRole("button", { name: /Refresh now|Refresh in/ }).click(); + expect((await refreshResponse).status()).toBe(200); + }, +); + +test( + "unknown user is told to install first", + { tag: "@local" }, + async ({ page }) => { + await page.goto(configureUrl(UNKNOWN_USER)); + await expect( + page.getByText("User not found.", { exact: false }), + ).toBeVisible(); + }, +); + +test( + "without userId, entering an id loads its configuration", + { tag: "@local" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(`${FRONTEND_URL}/configure`); + await page.locator("#imdb-id").fill(PUBLIC_USER); + await expect(page).toHaveURL(new RegExp(`userId=${PUBLIC_USER}`)); + await expect(page.getByText("Catalog 1")).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/home-onboarding.spec.ts b/apps/e2e/tests/home-onboarding.spec.ts new file mode 100644 index 0000000..8ccd944 --- /dev/null +++ b/apps/e2e/tests/home-onboarding.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl, FRONTEND_URL } from "../env.js"; +import { bootstrapUser } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + PUBLIC_USER, + PUBLIC_USER_2, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// First-install flow on the Stremlist home page. + +test.beforeEach(async ({ page }) => { + await resetDb(); + await page.goto(FRONTEND_URL); +}); + +test( + "new user gets install actions after live validation", + { tag: "@live-smoke" }, + async ({ page }) => { + await page.locator("#imdb-id").fill(PUBLIC_USER_2); + + const webInstall = page.getByRole("link", { name: "Open in Stremio Web" }); + await expect(webInstall).toBeVisible(); + await expect(webInstall).toHaveAttribute( + "href", + `https://web.stremio.com/#/addons?addon=${encodeURIComponent(addonManifestUrl(PUBLIC_USER_2))}`, + ); + await expect( + page.getByRole("link", { name: "Open in Stremio Desktop" }), + ).toHaveAttribute( + "href", + `stremio://127.0.0.1:7301/${PUBLIC_USER_2}/manifest.json`, + ); + }, +); + +test( + "returning user is welcomed back", + { tag: "@live-regression" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + await page.locator("#imdb-id").fill(PUBLIC_USER); + await expect(page.getByText(`Welcome back, ${PUBLIC_USER}!`)).toBeVisible(); + }, +); + +test( + "unknown IMDb id shows the not-found error", + { tag: "@live-regression" }, + async ({ page }) => { + await page.locator("#imdb-id").fill(UNKNOWN_USER); + await expect( + page.getByText( + "This IMDb ID does not exist. Please check and try again.", + ), + ).toBeVisible(); + }, +); + +test( + "garbage input shows the format error", + { tag: "@local" }, + async ({ page }) => { + await page.locator("#imdb-id").fill("banana"); + await expect( + page.getByText("Could not find a valid IMDb ID", { exact: false }), + ).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/stremio-catalogs.spec.ts b/apps/e2e/tests/stremio-catalogs.spec.ts new file mode 100644 index 0000000..a37799c --- /dev/null +++ b/apps/e2e/tests/stremio-catalogs.spec.ts @@ -0,0 +1,178 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl } from "../env.js"; +import { + bootstrapUser, + getCatalog, + getConfig, + postConfig, +} from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + discoverItemTitles, + discoverUrl, + installAddon, + uninstallAddon, +} from "../helpers/stremio.js"; +import { + PRIVATE_USER, + PUBLIC_USER, + UNKNOWN_USER, +} from "../helpers/test-data.js"; + +// Catalog rendering inside the real Stremio Web app. Expected content is read +// from the addon's own catalog endpoint in the same run, so assertions stay +// deterministic even though the underlying IMDb data is live. + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "watchlist catalog renders in Discover, in catalog order", + { tag: "@live-smoke" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + const manifestUrl = addonManifestUrl(PUBLIC_USER); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + expect(metas.length).toBeGreaterThan(0); + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + + const rendered = await discoverItemTitles(page); + expect(rendered.length).toBeGreaterThan(0); + const expected = metas.map((meta) => meta.name); + expect(rendered.slice(0, Math.min(5, expected.length))).toEqual( + expected.slice(0, Math.min(5, rendered.length)), + ); + }, +); + +test( + "sort option changes reorder the catalog without reinstalling", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + const manifestUrl = addonManifestUrl(PUBLIC_USER); + await getCatalog(PUBLIC_USER, "movie", catalogId); + await installAddon(page, manifestUrl); + + await postConfig(PUBLIC_USER, [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "title-asc", + }, + ]); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + const expected = metas.map((meta) => meta.name); + expect(expected).toEqual([...expected].sort((a, b) => a.localeCompare(b))); + + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + const rendered = await discoverItemTitles(page); + expect(rendered.slice(0, Math.min(5, expected.length))).toEqual( + expected.slice(0, Math.min(5, rendered.length)), + ); + }, +); + +test( + "built-in chart catalog renders after a reinstall", + { tag: "@live-regression" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + const manifestUrl = addonManifestUrl(PUBLIC_USER); + await installAddon(page, manifestUrl); + + // Adding a catalog changes the manifest, which Stremio only picks up on + // reinstall — exactly what the configure page tells the user to do. + await postConfig(PUBLIC_USER, [ + { imdbUserId: PUBLIC_USER, sortOption: "added_at-asc" }, + { + imdbUserId: "imdb:box-office", + sortOption: "added_at-asc", + displayMode: "movie", + }, + ]); + const { body } = await getConfig(PUBLIC_USER); + const chart = body.watchlists.find( + (w) => w.imdbUserId === "imdb:box-office", + ); + expect(chart).toBeDefined(); + const catalogId = `wl-${chart!.id}-movie`; + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + expect(metas.length).toBeGreaterThan(0); + + await uninstallAddon(page, manifestUrl); + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + + const rendered = await discoverItemTitles(page); + expect(rendered.length).toBeGreaterThan(0); + expect(rendered[0]).toBe(metas[0].name); + }, +); + +test( + "catalog rows appear on the Board", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + // Distinctive title so the Board row is unambiguous. + await postConfig(PUBLIC_USER, [ + { + id: watchlist.id, + imdbUserId: watchlist.imdbUserId, + sortOption: "added_at-asc", + catalogTitle: "E2E QA", + }, + ]); + const manifestUrl = addonManifestUrl(PUBLIC_USER); + await getCatalog(PUBLIC_USER, "movie", `wl-${watchlist.id}-movie`); + + await installAddon(page, manifestUrl); + await page.goto("https://web.stremio.com/#/"); + await expect( + page.getByText("Stremlist E2E QA", { exact: false }).first(), + ).toBeAttached({ timeout: 30_000 }); + }, +); + +test( + "broken watchlist shows the informational card in Stremio", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(UNKNOWN_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const manifestUrl = addonManifestUrl(UNKNOWN_USER); + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + await expect( + page.getByText("IMDb watchlist not found", { exact: false }).first(), + ).toBeVisible(); + }, +); + +test( + "private watchlist shows the private card in Stremio", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PRIVATE_USER); + const catalogId = `wl-${config.watchlists[0].id}-movie`; + const manifestUrl = addonManifestUrl(PRIVATE_USER); + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + await expect( + page + .getByText("This IMDb watchlist is private", { exact: false }) + .first(), + ).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/stremio-install.spec.ts b/apps/e2e/tests/stremio-install.spec.ts new file mode 100644 index 0000000..0151f5e --- /dev/null +++ b/apps/e2e/tests/stremio-install.spec.ts @@ -0,0 +1,65 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl, FRONTEND_URL } from "../env.js"; +import { bootstrapUser } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { + addonsDeepLink, + dismissDesktopAppPrompt, + installAddon, +} from "../helpers/stremio.js"; +import { PUBLIC_USER } from "../helpers/test-data.js"; + +// Install lifecycle inside the real Stremio Web app (anonymous profile — +// a fresh browser context has its own local addon collection). + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "installs and uninstalls the addon through Stremio Web", + { tag: "@live-smoke" }, + async ({ page }) => { + await bootstrapUser(PUBLIC_USER); + const manifestUrl = addonManifestUrl(PUBLIC_USER); + + await installAddon(page, manifestUrl); + + // Re-opening the deep link on an installed addon offers Uninstall. + await page.goto(addonsDeepLink(manifestUrl)); + await page.reload(); + await dismissDesktopAppPrompt(page); + const uninstall = page.getByText("Uninstall", { exact: true }).last(); + await expect(uninstall).toBeVisible(); + await uninstall.click(); + + // And once uninstalled, the same deep link offers Install again. + await page.goto(addonsDeepLink(manifestUrl)); + await page.reload(); + await dismissDesktopAppPrompt(page); + await expect( + page.getByText("Install", { exact: true }).last(), + ).toBeVisible(); + }, +); + +test( + "configure page links straight into Stremio Web's install dialog", + { tag: "@live-regression" }, + async ({ page, context }) => { + await bootstrapUser(PUBLIC_USER); + await page.goto(`${FRONTEND_URL}/configure?userId=${PUBLIC_USER}`); + + const popupPromise = context.waitForEvent("page"); + await page.getByRole("link", { name: "Open in Stremio Web" }).click(); + const popup = await popupPromise; + await popup.waitForLoadState(); + expect(popup.url()).toBe( + `https://web.stremio.com/#/addons?addon=${encodeURIComponent(addonManifestUrl(PUBLIC_USER))}`, + ); + await dismissDesktopAppPrompt(popup); + await expect( + popup.getByText("Install", { exact: true }).last(), + ).toBeVisible(); + }, +); diff --git a/apps/e2e/tests/stremio-meta.spec.ts b/apps/e2e/tests/stremio-meta.spec.ts new file mode 100644 index 0000000..cff1960 --- /dev/null +++ b/apps/e2e/tests/stremio-meta.spec.ts @@ -0,0 +1,37 @@ +import { expect, test } from "@playwright/test"; +import { addonManifestUrl } from "../env.js"; +import { bootstrapUser, getCatalog } from "../helpers/api.js"; +import { resetDb } from "../helpers/db.js"; +import { discoverUrl, installAddon } from "../helpers/stremio.js"; +import { PUBLIC_USER } from "../helpers/test-data.js"; + +// Clicking through from a Stremlist catalog to a detail page in Stremio Web. + +test.beforeEach(async () => { + await resetDb(); +}); + +test( + "catalog items open their detail page", + { tag: "@live-regression" }, + async ({ page }) => { + const config = await bootstrapUser(PUBLIC_USER); + const watchlist = config.watchlists[0]; + const catalogId = `wl-${watchlist.id}-movie`; + const manifestUrl = addonManifestUrl(PUBLIC_USER); + const { metas } = await getCatalog(PUBLIC_USER, "movie", catalogId); + const first = metas[0]; + + await installAddon(page, manifestUrl); + await page.goto(discoverUrl(manifestUrl, "movie", catalogId)); + + const firstItem = page.locator('a[href^="#/detail/"]').first(); + await expect(firstItem).toBeVisible(); + await firstItem.click(); + + await expect(page).toHaveURL(new RegExp(`#/detail/movie/${first.id}`)); + // The detail page renders the title as a logo image, not text — assert on + // imagery for the exact tt id we clicked (logo/background src embed it). + await expect(page.locator(`img[src*="${first.id}"]`).first()).toBeVisible(); + }, +); diff --git a/apps/e2e/tsconfig.json b/apps/e2e/tsconfig.json new file mode 100644 index 0000000..32ca233 --- /dev/null +++ b/apps/e2e/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "types": ["node"], + "noEmit": true + }, + "include": ["tests", "helpers", "*.ts"], + "exclude": ["node_modules"] +} diff --git a/package.json b/package.json index 7be5994..970b1ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "stremlist", - "version": "1.6.0", + "version": "1.7.0", "private": true, "description": "Stremlist - Sync your IMDb watchlist with Stremio", "repository": { @@ -28,6 +28,7 @@ "format": "turbo run format", "format:check": "turbo run format:check", "test": "turbo run test", + "test:e2e": "turbo run test:e2e", "typecheck": "turbo run typecheck", "generate:types": "pnpm supabase gen types typescript --project-id igbmxubzxjfdbgmawrtr --schema public > packages/shared/src/database.types.ts", "prepare": "husky" diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index bf66a88..218b118 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,7 +1,7 @@ import type { StremioManifest } from "./stremio.types"; export const APP_NAME = "Stremlist"; -export const ADDON_VERSION = "1.6.0"; +export const ADDON_VERSION = "1.7.0"; export const APP_DESCRIPTION = "Your IMDb Watchlist in Stremio"; export const APP_LOGO = "https://stremlist.com/icon.png"; export const APP_ID_PREFIX = "com.stremlist"; diff --git a/packages/shared/src/stremio.types.ts b/packages/shared/src/stremio.types.ts index 2ddef38..bd68b20 100644 --- a/packages/shared/src/stremio.types.ts +++ b/packages/shared/src/stremio.types.ts @@ -26,7 +26,7 @@ export interface UserConfigUpdateWatchlist { catalogTitle?: string; sortOption: string; displayMode?: DisplayMode; - position: number; + position?: number; } export interface UserConfigUpdatePayload { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a99ea8a..6a29e7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -82,6 +82,45 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@20.19.33)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + apps/e2e: + devDependencies: + '@playwright/test': + specifier: ^1.62.1 + version: 1.62.1 + '@stremlist/backend': + specifier: workspace:* + version: link:../backend + '@stremlist/eslint-config': + specifier: workspace:* + version: link:../../packages/eslint-config + '@stremlist/frontend': + specifier: workspace:* + version: link:../frontend + '@stremlist/shared': + specifier: workspace:* + version: link:../../packages/shared + '@supabase/supabase-js': + specifier: ^2.95.3 + version: 2.95.3 + '@types/node': + specifier: ^20.11.17 + version: 20.19.33 + eslint: + specifier: ^9.39.2 + version: 9.39.2(jiti@2.6.1) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@9.39.2(jiti@2.6.1)) + prettier: + specifier: ^3.8.1 + version: 3.8.1 + tsx: + specifier: ^4.7.1 + version: 4.21.0 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + apps/frontend: dependencies: '@dnd-kit/helpers': @@ -782,6 +821,11 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} + engines: {node: '>=20'} + hasBin: true + '@preact/signals-core@1.14.0': resolution: {integrity: sha512-AowtCcCU/33lFlh1zRFf/u+12rfrhtNakj7UpaGEsmMwUKpKWMVvcktOGcwBBNiB4lWrZWc01LhiyyzVklJyaQ==} @@ -2054,6 +2098,11 @@ packages: react-dom: optional: true + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2425,6 +2474,16 @@ packages: engines: {node: '>=0.10'} hasBin: true + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} + engines: {node: '>=20'} + hasBin: true + postal-mime@2.7.3: resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} @@ -3315,6 +3374,10 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@playwright/test@1.62.1': + dependencies: + playwright: 1.62.1 + '@preact/signals-core@1.14.0': {} '@radix-ui/number@1.1.1': {} @@ -4484,6 +4547,9 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -4778,6 +4844,14 @@ snapshots: pidtree@0.6.0: {} + playwright-core@1.62.1: {} + + playwright@1.62.1: + dependencies: + playwright-core: 1.62.1 + optionalDependencies: + fsevents: 2.3.2 + postal-mime@2.7.3: {} postcss@8.5.6: diff --git a/supabase/.gitignore b/supabase/.gitignore new file mode 100644 index 0000000..ad9264f --- /dev/null +++ b/supabase/.gitignore @@ -0,0 +1,8 @@ +# Supabase +.branches +.temp + +# dotenvx +.env.keys +.env.local +.env.*.local diff --git a/supabase/config.toml b/supabase/config.toml new file mode 100644 index 0000000..2cdde7b --- /dev/null +++ b/supabase/config.toml @@ -0,0 +1,408 @@ +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +# A string used to distinguish different Supabase projects on the same host. Defaults to the +# working directory name when running `supabase init`. +project_id = "stremlist" + +[api] +enabled = true +# Port to use for the API URL. +port = 54321 +# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API +# endpoints. `public` and `graphql_public` schemas are included by default. +schemas = ["public", "graphql_public"] +# Extra schemas to add to the search_path of every request. +extra_search_path = ["public", "extensions"] +# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size +# for accidental or malicious requests. +max_rows = 1000 + +[api.tls] +# Enable HTTPS endpoints locally using a self-signed certificate. +enabled = false +# Paths to self-signed certificate pair. +# cert_path = "../certs/my-cert.pem" +# key_path = "../certs/my-key.pem" + +[db] +# Port to use for the local database URL. +port = 54322 +# Port used by db diff command to initialize the shadow database. +shadow_port = 54320 +# Maximum amount of time to wait for health check when starting the local database. +health_timeout = "2m" +# The database major version to use. This has to be the same as your remote database's. Run `SHOW +# server_version;` on the remote database to check. +major_version = 17 + +[db.pooler] +enabled = false +# Port to use for the local connection pooler. +port = 54329 +# Specifies when a server connection can be reused by other clients. +# Configure one of the supported pooler modes: `transaction`, `session`. +pool_mode = "transaction" +# How many server connections to allow per user/database pair. +default_pool_size = 20 +# Maximum number of client connections allowed. +max_client_conn = 100 + +# [db.vault] +# secret_key = "env(SECRET_VALUE)" + +[db.migrations] +# If disabled, migrations will be skipped during a db push or reset. +enabled = true +# Specifies an ordered list of schema files that describe your database. +# Supports glob patterns relative to supabase directory: "./schemas/*.sql" +schema_paths = [] + +[db.seed] +# If enabled, seeds the database after migrations during a db reset. +enabled = true +# Specifies an ordered list of seed files to load during db reset. +# Supports glob patterns relative to supabase directory: "./seeds/*.sql" +sql_paths = ["./seed.sql"] + +[db.network_restrictions] +# Enable management of network restrictions. +enabled = false +# List of IPv4 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv4 connections. Set empty array to block all IPs. +allowed_cidrs = ["0.0.0.0/0"] +# List of IPv6 CIDR blocks allowed to connect to the database. +# Defaults to allow all IPv6 connections. Set empty array to block all IPs. +allowed_cidrs_v6 = ["::/0"] + +# Uncomment to reject non-secure connections to the database. +# [db.ssl_enforcement] +# enabled = true + +[realtime] +enabled = true +# Bind realtime via either IPv4 or IPv6. (default: IPv4) +# ip_version = "IPv6" +# The maximum length in bytes of HTTP request headers. (default: 4096) +# max_header_length = 4096 + +[studio] +enabled = true +# Port to use for Supabase Studio. +port = 54323 +# External URL of the API server that frontend connects to. +api_url = "http://127.0.0.1" +# OpenAI API Key to use for Supabase AI in the Supabase Studio. +openai_api_key = "env(OPENAI_API_KEY)" + +# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they +# are monitored, and you can view the emails that would have been sent from the web interface. +[inbucket] +enabled = true +# Port to use for the email testing server web interface. +port = 54324 +# Uncomment to expose additional ports for testing user applications that send emails. +# smtp_port = 54325 +# pop3_port = 54326 +# admin_email = "admin@email.com" +# sender_name = "Admin" + +[storage] +enabled = true +# The maximum file size allowed (e.g. "5MB", "500KB"). +file_size_limit = "50MiB" + +# Uncomment to configure local storage buckets +# [storage.buckets.images] +# public = false +# file_size_limit = "50MiB" +# allowed_mime_types = ["image/png", "image/jpeg"] +# objects_path = "./images" + +# Allow connections via S3 compatible clients +[storage.s3_protocol] +enabled = true + +# Image transformation API is available to Supabase Pro plan. +# [storage.image_transformation] +# enabled = true + +# Store analytical data in S3 for running ETL jobs over Iceberg Catalog +# This feature is only available on the hosted platform. +[storage.analytics] +enabled = false +max_namespaces = 5 +max_tables = 10 +max_catalogs = 2 + +# Analytics Buckets is available to Supabase Pro plan. +# [storage.analytics.buckets.my-warehouse] + +# Store vector embeddings in S3 for large and durable datasets +# This feature is only available on the hosted platform. +[storage.vector] +enabled = false +max_buckets = 10 +max_indexes = 5 + +# Vector Buckets is available to Supabase Pro plan. +# [storage.vector.buckets.documents-openai] + +[auth] +enabled = true +# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used +# in emails. +site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" +# A list of *exact* URLs that auth providers are permitted to redirect to post authentication. +additional_redirect_urls = ["https://127.0.0.1:3000"] +# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). +jwt_expiry = 3600 +# JWT issuer URL. If not set, defaults to auth.external_url. +# jwt_issuer = "" +# Path to JWT signing key. DO NOT commit your signing keys file to git. +# signing_keys_path = "./signing_keys.json" +# If disabled, the refresh token will never expire. +enable_refresh_token_rotation = true +# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. +# Requires enable_refresh_token_rotation = true. +refresh_token_reuse_interval = 10 +# Allow/disallow new user signups to your project. +enable_signup = true +# Allow/disallow anonymous sign-ins to your project. +enable_anonymous_sign_ins = false +# Allow/disallow testing manual linking of accounts +enable_manual_linking = false +# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more. +minimum_password_length = 6 +# Passwords that do not meet the following requirements will be rejected as weak. Supported values +# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols` +password_requirements = "" + +# Configure passkey sign-ins. +# [auth.passkey] +# enabled = false + +# Configure WebAuthn relying party settings (required when passkey is enabled). +# [auth.webauthn] +# rp_display_name = "Supabase" +# rp_id = "localhost" +# rp_origins = ["http://127.0.0.1:3000"] + +[auth.rate_limit] +# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled. +email_sent = 2 +# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled. +sms_sent = 30 +# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true. +anonymous_users = 30 +# Number of sessions that can be refreshed in a 5 minute interval per IP address. +token_refresh = 150 +# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users). +sign_in_sign_ups = 30 +# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address. +token_verifications = 30 +# Number of Web3 logins that can be made in a 5 minute interval per IP address. +web3 = 30 + +# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`. +# [auth.captcha] +# enabled = true +# provider = "hcaptcha" +# secret = "" + +[auth.email] +# Allow/disallow new user signups via email to your project. +enable_signup = true +# If enabled, a user will be required to confirm any email change on both the old, and new email +# addresses. If disabled, only the new email is required to confirm. +double_confirm_changes = true +# If enabled, users need to confirm their email address before signing in. +enable_confirmations = false +# If enabled, users will need to reauthenticate or have logged in recently to change their password. +secure_password_change = false +# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. +max_frequency = "1s" +# Number of characters used in the email OTP. +otp_length = 6 +# Number of seconds before the email OTP expires (defaults to 1 hour). +otp_expiry = 3600 + +# Use a production-ready SMTP server +# [auth.email.smtp] +# enabled = true +# host = "smtp.sendgrid.net" +# port = 587 +# user = "apikey" +# pass = "env(SENDGRID_API_KEY)" +# admin_email = "admin@email.com" +# sender_name = "Admin" + +# Uncomment to customize email template +# [auth.email.template.invite] +# subject = "You have been invited" +# content_path = "./supabase/templates/invite.html" + +# Uncomment to customize notification email template +# [auth.email.notification.password_changed] +# enabled = true +# subject = "Your password has been changed" +# content_path = "./templates/password_changed_notification.html" + +[auth.sms] +# Allow/disallow new user signups via SMS to your project. +enable_signup = false +# If enabled, users need to confirm their phone number before signing in. +enable_confirmations = false +# Template for sending OTP to users +template = "Your code is {{ .Code }}" +# Controls the minimum amount of time that must pass before sending another sms otp. +max_frequency = "5s" + +# Use pre-defined map of phone number to OTP for testing. +# [auth.sms.test_otp] +# 4152127777 = "123456" + +# Configure logged in session timeouts. +# [auth.sessions] +# Force log out after the specified duration. +# timebox = "24h" +# Force log out if the user has been inactive longer than the specified duration. +# inactivity_timeout = "8h" + +# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object. +# [auth.hook.before_user_created] +# enabled = true +# uri = "pg-functions://postgres/auth/before-user-created-hook" + +# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. +# [auth.hook.custom_access_token] +# enabled = true +# uri = "pg-functions:////" + +# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +# Multi-factor-authentication is available to Supabase Pro plan. +[auth.mfa] +# Control how many MFA factors can be enrolled at once per user. +max_enrolled_factors = 10 + +# Control MFA via App Authenticator (TOTP) +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +# Configure MFA via Phone Messaging +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +# Configure MFA via WebAuthn +# [auth.mfa.web_authn] +# enroll_enabled = true +# verify_enabled = true + +# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, +# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, +# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`. +[auth.external.apple] +enabled = false +client_id = "" +# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: +secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" +# Overrides the default auth callback URL derived from auth.external_url. +redirect_uri = "" +# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, +# or any other third-party OIDC providers. +url = "" +# If enabled, the nonce check will be skipped. Required for local sign in with Google auth. +skip_nonce_check = false +# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address. +email_optional = false + +# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard. +# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting. +[auth.web3.solana] +enabled = false + +# Use Firebase Auth as a third-party provider alongside Supabase Auth. +[auth.third_party.firebase] +enabled = false +# project_id = "my-firebase-project" + +# Use Auth0 as a third-party provider alongside Supabase Auth. +[auth.third_party.auth0] +enabled = false +# tenant = "my-auth0-tenant" +# tenant_region = "us" + +# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. +[auth.third_party.aws_cognito] +enabled = false +# user_pool_id = "my-user-pool-id" +# user_pool_region = "us-east-1" + +# Use Clerk as a third-party provider alongside Supabase Auth. +[auth.third_party.clerk] +enabled = false +# Obtain from https://clerk.com/setup/supabase +# domain = "example.clerk.accounts.dev" + +# OAuth server configuration +[auth.oauth_server] +# Enable OAuth server functionality +enabled = false +# Path for OAuth consent flow UI +authorization_url_path = "/oauth/consent" +# Allow dynamic client registration +allow_dynamic_registration = false + +[edge_runtime] +enabled = true +# Supported request policies: `oneshot`, `per_worker`. +# `per_worker` (default) — enables hot reload during local development. +# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks). +policy = "per_worker" +# Port to attach the Chrome inspector for debugging edge functions. +inspector_port = 8083 +# The Deno major version to use. +deno_version = 2 + +# [edge_runtime.secrets] +# secret_key = "env(SECRET_VALUE)" + +[analytics] +enabled = true +port = 54327 +# Configure one of the supported backends: `postgres`, `bigquery`. +backend = "postgres" + +# Experimental features may be deprecated any time +[experimental] +# Configures Postgres storage engine to use OrioleDB (S3) +orioledb_version = "" +# Configures S3 bucket URL, eg. .s3-.amazonaws.com +s3_host = "env(S3_HOST)" +# Configures S3 bucket region, eg. us-east-1 +s3_region = "env(S3_REGION)" +# Configures AWS_ACCESS_KEY_ID for S3 bucket +s3_access_key = "env(S3_ACCESS_KEY)" +# Configures AWS_SECRET_ACCESS_KEY for S3 bucket +s3_secret_key = "env(S3_SECRET_KEY)" + +# [experimental.pgdelta] +# When enabled, pg-delta becomes the active engine for supported schema flows. +# enabled = false +# Directory under `supabase/` where declarative files are written. +# declarative_schema_path = "./database" +# JSON string passed through to pg-delta SQL formatting. +# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" diff --git a/supabase/migrations/20260219000000_baseline_schema.sql b/supabase/migrations/20260219000000_baseline_schema.sql new file mode 100644 index 0000000..4011b8f --- /dev/null +++ b/supabase/migrations/20260219000000_baseline_schema.sql @@ -0,0 +1,21 @@ +-- Baseline for environments created from scratch (local dev, CI). +-- The production database predates migration tracking: `users` and the legacy +-- `watchlist_cache` blob table were created by hand, so the oldest committed +-- migration (20260220000000_enable_rls) assumes they already exist. Everything +-- here is IF NOT EXISTS so pushing this migration to production is a no-op. + +CREATE TABLE IF NOT EXISTS public.users ( + imdb_user_id text PRIMARY KEY, + is_active boolean NOT NULL DEFAULT true, + last_fetched_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now(), + last_cache_served_at timestamptz +); + +-- Legacy blob cache, unused by the application since watchlist_cache_items. +-- Kept only because later migrations reference it. +CREATE TABLE IF NOT EXISTS public.watchlist_cache ( + imdb_user_id text PRIMARY KEY REFERENCES public.users(imdb_user_id) ON DELETE CASCADE, + cached_data jsonb, + cached_at timestamptz NOT NULL DEFAULT now() +); diff --git a/turbo.json b/turbo.json index d0071d3..f1c48f2 100644 --- a/turbo.json +++ b/turbo.json @@ -22,6 +22,9 @@ "test": { "dependsOn": ["^build"] }, + "test:e2e": { + "cache": false + }, "typecheck": { "dependsOn": ["^build"] }