diff --git a/docs/payment-e2e-skip-index.md b/docs/payment-e2e-skip-index.md index 26ccdc3a..b96d472f 100644 --- a/docs/payment-e2e-skip-index.md +++ b/docs/payment-e2e-skip-index.md @@ -1,102 +1,96 @@ # Payment E2E `test.skip` Index (#53) -**Generated 2026-06-05.** Indexes every skipped test in `tests/e2e/payment/` by its -**blocker**, so it's clear what must ship before each can be un-skipped — and that -none are skipped without a tracked reason. +**Regenerated 2026-06-08.** Indexes every skipped test in `tests/e2e/payment/` by +its **blocker**, so it's clear what must ship before each can be un-skipped — and +that none are skipped without a tracked reason. Line numbers and reasons below are +the ground truth in the specs as of this date (verified by grepping +`test.skip(true, ...)`), not a hand-maintained guess. ## Summary -`tests/e2e/payment/` contains **34 skipped tests** (via `test.skip(true, '')`), -across 7 spec files. They fall into these blocker buckets: - -| Blocker | Count | Unblocked by | -| ------------------------------ | ----- | ----------------------------------------------------- | -| Other missing UI feature | 10 | various small UI surfaces | -| Route `/payment/subscriptions` | 5 | SUBSCRIPTION-MGMT (#5) | -| Offline-queue UI | 5 | PAYMENT-OFFLINE-UI (#4) | -| Live provider keys / webhooks | 4 | PayPal/Stripe **sandbox credentials** (deploy-time) | -| Route `/payment/dashboard` | 2 | PAYMENT-DASHBOARD (#3) | -| Route `/payment/history` | 2 | PAYMENT-DASHBOARD (#3) | -| Realtime dashboard UI | 2 | PAYMENT-DASHBOARD (#3 / #6) | -| Grace-period wiring | 1 | SUBSCRIPTION-MGMT (#5) | -| Duplicate-prevention | 1 | dup-prevention feature | -| Test-env limitation | 2 | won't-fix in E2E (FPS / bundling — covered elsewhere) | +`tests/e2e/payment/` contains **27 skipped tests** (each via +`test.skip(true, '')`), across 7 spec files. They fall into these blocker +buckets: -**Key takeaways for un-skipping:** - -- The biggest unlocks are the **payment UI routes** (#3 PAYMENT-DASHBOARD, #4 - PAYMENT-OFFLINE-UI, #5 SUBSCRIPTION-MGMT) — shipping those routes un-blocks ~17 tests. -- **4 tests need live sandbox credentials** (full Stripe/PayPal redirect + webhook - flows) — these stay skipped in CI by design; run them locally with sandbox keys. -- The 2 test-env-limitation skips (FPS measurement, script bundling) are not real - product gaps; leave skipped. - -> Note: the Edge Functions those flows call (PayPal order/capture/subscription, -> cancel/resume) shipped in #130 — so the remaining payment blockers are the -> **front-end routes** + **sandbox creds**, not the backend. - -## Full index by blocker - -### Route /payment/subscriptions — SUBSCRIPTION-MGMT (#5) — 5 skipped - -- `02-paypal-subscription.spec.ts:85` — Subscription management page not yet implemented -- `02-paypal-subscription.spec.ts:90` — Subscription management page not yet implemented -- `02-paypal-subscription.spec.ts:95` — Subscription management page not yet implemented -- `03-failed-payment-retry.spec.ts:136` — Subscription management page not yet implemented -- `06-realtime-dashboard.spec.ts:99` — Subscription management page not yet implemented +| Blocker | Count | Unblocked by | +| ----------------------------------- | ----- | ------------------------------------------------------------------- | +| Unimplemented dashboard/realtime UI | 11 | small UI surfaces on `/payment/dashboard` + realtime widgets (#6) | +| Live provider keys / webhooks | 6 | Stripe/PayPal **sandbox credentials** set on the deployed functions | +| Offline-queue seed fixture | 4 | an in-page Dexie queue-seed fixture (autonomous; see note below) | +| Unimplemented route/page | 3 | `/payment/subscriptions`, `/payment/history` routes | +| Won't-fix in E2E | 2 | FPS measurement + script-bundling (covered elsewhere / unreliable) | +| Edge-function flow | 1 | exercising the `cancel-subscription` Edge Function end-to-end | -### Route /payment/dashboard — PAYMENT-DASHBOARD (#3) — 2 skipped +**Just un-skipped (2026-06-08, no creds):** the **grace-period countdown** and +**duplicate-prevention (23505)** tests in `02-paypal-subscription.spec.ts` now run +against a seeded subscription row via the new `seedIsolatedSubscription` fixture +(`tests/e2e/utils/test-user-factory.ts`). They needed a fixture, not credentials. -- `07-performance.spec.ts:26` — Payment dashboard page not yet implemented -- `07-performance.spec.ts:33` — Payment dashboard page not yet implemented - -### Route /payment/history — PAYMENT-DASHBOARD (#3) — 2 skipped - -- `05-offline-queue.spec.ts:115` — Payment history page not yet implemented -- `07-performance.spec.ts:40` — Payment history page not yet implemented - -### Offline-queue UI — PAYMENT-OFFLINE-UI (#4) — 5 skipped - -- `01-stripe-onetime.spec.ts:149` — Offline queue feature not yet implemented -- `05-offline-queue.spec.ts:67` — Offline queue status UI not yet implemented -- `05-offline-queue.spec.ts:75` — Queue sync status UI not yet implemented -- `05-offline-queue.spec.ts:91` — Queue persistence UI not yet implemented -- `07-performance.spec.ts:48` — Offline queue sync UI not yet implemented +**Key takeaways for un-skipping:** -### Realtime dashboard UI — PAYMENT-DASHBOARD (#3/#6) — 2 skipped +- **6 tests need live sandbox credentials** (full Stripe/PayPal redirect + webhook + flows). With the Edge Functions now deployed to prod, the only remaining gate is + setting the provider secrets (`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, + `PAYPAL_CLIENT_SECRET`, `PAYPAL_WEBHOOK_ID`) via the Management API `/secrets` + endpoint. These stay skipped in CI by design. +- **4 offline-queue tests need a Dexie seed fixture.** These are empty stubs whose + bodies must also be written; the queue is client-side (`PaymentQueueV2` / + `queuedOperations` Dexie store, via `@/lib/offline-queue/payment-adapter`), so the + fixture must seed IndexedDB in-page (call the app's own `paymentQueue` API), not + the server `payment_intents` table. Autonomous but non-trivial — deferred. +- **The remaining ~16** are genuinely unimplemented UI surfaces or routes + (realtime widgets, `/payment/subscriptions`, `/payment/history`) and 2 + won't-fix-in-E2E perf cases. They are NOT credential-blocked. + +> Note: the Edge Functions these flows call (Stripe checkout/subscription/verify, +> PayPal order/capture/subscription, cancel/resume, the webhooks) are all DEPLOYED +> to prod as of 2026-06-08 (via the Supabase Management API). So the remaining +> payment blockers are **sandbox creds** + **a few front-end surfaces**, not the +> backend code. -- `06-realtime-dashboard.spec.ts:104` — Transaction counter not yet implemented -- `06-realtime-dashboard.spec.ts:130` — Real-time error notifications not yet implemented +## Full index by blocker -### Live provider keys / webhooks (sandbox creds) — 4 skipped +### Live provider keys / webhooks (sandbox creds) — 6 skipped -- `02-paypal-subscription.spec.ts:49` — PayPal API keys not configured - skipping flow test +- `02-paypal-subscription.spec.ts:56` — PayPal API keys not configured - skipping flow test +- `02-paypal-subscription.spec.ts:124` — Needs a seeded past_due/grace row + PayPal sandbox keys - `06-realtime-dashboard.spec.ts:85` — Payment list updates require actual Stripe integration - `06-realtime-dashboard.spec.ts:92` — Webhook verification requires actual Stripe webhooks - `07-performance.spec.ts:19` — Stripe API keys not configured - use k6 for load testing +- `07-performance.spec.ts:116` — Script bundling test requires Stripe integration -### Grace-period wiring (#5) — 1 skipped +### Offline-queue seed fixture (autonomous — Dexie) — 4 skipped -- `02-paypal-subscription.spec.ts:100` — Grace period feature not yet implemented +- `05-offline-queue.spec.ts:102` — Needs a queue-seed fixture or provider creds to enqueue +- `05-offline-queue.spec.ts:110` — Needs a queue-seed fixture or provider creds to enqueue +- `05-offline-queue.spec.ts:118` — Needs a queue-seed fixture to enqueue multiple items +- `05-offline-queue.spec.ts:137` — Needs a seeded failed item to exercise backoff/retry UI -### Duplicate-prevention feature — 1 skipped +### Edge-function flow — 1 skipped -- `02-paypal-subscription.spec.ts:105` — Duplicate subscription prevention not yet implemented +- `02-paypal-subscription.spec.ts:120` — Cancel drives the cancel-subscription Edge Function -### Other missing UI feature — 10 skipped +### Unimplemented route/page — 3 skipped + +- `03-failed-payment-retry.spec.ts:136` — Subscription management page not yet implemented +- `06-realtime-dashboard.spec.ts:99` — Subscription management page not yet implemented +- `07-performance.spec.ts:40` — Payment history page not yet implemented +### Unimplemented dashboard / realtime UI — 11 skipped + +- `01-stripe-onetime.spec.ts:149` — Offline queue feature not yet implemented - `04-gdpr-consent.spec.ts:232` — Consent reset feature not yet implemented -- `05-offline-queue.spec.ts:83` — Queue count display not yet implemented -- `05-offline-queue.spec.ts:99` — Retry status UI not yet implemented -- `05-offline-queue.spec.ts:107` — Max retry UI not yet implemented -- `05-offline-queue.spec.ts:123` — Queue overflow handling not yet implemented -- `05-offline-queue.spec.ts:128` — Clear queue button not yet implemented +- `06-realtime-dashboard.spec.ts:104` — Transaction counter not yet implemented - `06-realtime-dashboard.spec.ts:112` — Offline status indicator not yet implemented - `06-realtime-dashboard.spec.ts:120` — Reconnection UI not yet implemented - `06-realtime-dashboard.spec.ts:125` — Batch update UI not yet implemented +- `06-realtime-dashboard.spec.ts:130` — Real-time error notifications not yet implemented - `06-realtime-dashboard.spec.ts:135` — Payment chart not yet implemented +- `07-performance.spec.ts:26` — Payment dashboard page not yet implemented +- `07-performance.spec.ts:33` — Payment dashboard page not yet implemented +- `07-performance.spec.ts:48` — Offline queue sync UI not yet implemented -### Uncategorized (test-env limitations) — 2 skipped +### Won't-fix in E2E — 2 skipped +- `05-offline-queue.spec.ts:167` — Overflow/storage-warning handling not in scope for #4 - `07-performance.spec.ts:111` — FPS testing is not reliable in E2E tests -- `07-performance.spec.ts:116` — Script bundling test requires Stripe integration diff --git a/tests/e2e/payment/02-paypal-subscription.spec.ts b/tests/e2e/payment/02-paypal-subscription.spec.ts index 0f4b2985..81adb7b9 100644 --- a/tests/e2e/payment/02-paypal-subscription.spec.ts +++ b/tests/e2e/payment/02-paypal-subscription.spec.ts @@ -8,7 +8,14 @@ */ import { test, expect } from '@playwright/test'; -import { dismissCookieBanner } from '../utils/test-user-factory'; +import { + dismissCookieBanner, + getAdminClient, + seedIsolatedSubscription, + deleteIsolatedSubscription, + openSubscriptionsAs, + type IsolatedSubscription, +} from '../utils/test-user-factory'; test.describe('PayPal Subscription Creation Flow', () => { test.describe.configure({ timeout: 60000 }); @@ -100,34 +107,92 @@ test.describe('PayPal Subscription Creation Flow', () => { ).toBeVisible(); }); - // The flows below assert behavior against a SEEDED subscription row (cancel, - // grace-period countdown, duplicate-prevention). They need the per-test - // subscription-seeding fixture (service-role insert + cleanup) which isn't - // wired yet — the route + grace UI + 23505 guard themselves are covered by - // SubscriptionManager.test.tsx and the migration. Un-skip once seeding lands. + // The grace-period + duplicate-prevention flows below run against a SEEDED + // subscription row via the per-test fixture (seedIsolatedSubscription — + // service-role insert + cleanup, no provider creds). Cancel + failed-retry + // remain skipped: cancel drives the cancel-subscription edge function and + // failed-retry needs PayPal sandbox keys. + test.skip('should allow subscription cancellation', async ({ page }) => { - test.skip( - true, - 'Needs a seeded subscription row (per-test fixture) to drive cancel' - ); + // Fixture exists (seedIsolatedSubscription) but cancel drives the + // cancel-subscription Edge Function end-to-end; un-skip once that flow is + // exercised against a deployed function with provider config. + test.skip(true, 'Cancel drives the cancel-subscription Edge Function'); }); test.skip('should handle failed payment retry logic', async ({ page }) => { test.skip(true, 'Needs a seeded past_due/grace row + PayPal sandbox keys'); }); - test.skip('should show grace period warning', async ({ page }) => { - // Covered as a component test in SubscriptionManager.test.tsx; an E2E here - // needs a seeded grace_period row. - test.skip(true, 'Needs a seeded grace_period subscription row'); + test('should show grace period warning', async ({ browser }) => { + // Seed a grace_period subscription (expires in 5 days) for a throwaway user, + // open /account/subscriptions AS that user, and assert the countdown + badge + // render — driven by a real row through real RLS (no provider creds needed). + let fixture: IsolatedSubscription | null = null; + let opened: Awaited> | null = null; + try { + fixture = await seedIsolatedSubscription('grace_period', { + provider: 'stripe', + graceDays: 5, + }); + // No admin client / anon key (unconfigured env) → skip rather than fail. + test.skip(!fixture, 'Admin client unavailable to seed subscription'); + if (!fixture) return; + + opened = await openSubscriptionsAs(browser, fixture); + const { page } = opened; + + await expect( + page.getByRole('heading', { name: 'Subscriptions', level: 1 }) + ).toBeVisible({ timeout: 30000 }); + + // Grace-period alert with the day countdown (graceDays=5). + await expect( + page.getByText(/Grace period: 5 days remaining/i) + ).toBeVisible({ timeout: 30000 }); + // The status badge. + await expect(page.getByText(/Grace Period/i).first()).toBeVisible(); + } finally { + if (opened) await opened.close(); + await deleteIsolatedSubscription(fixture); + } }); - test.skip('should prevent duplicate subscriptions', async ({ page }) => { - // The DB-level guard (idx_subscriptions_one_live_per_user) + webhook 23505 - // catch enforce this server-side; a browser E2E needs a seeded live row. - test.skip( - true, - 'Needs a seeded live subscription row to trigger the guard' - ); + test('should prevent duplicate subscriptions', async () => { + // The one-live-per-user guard (idx_subscriptions_one_live_per_user) is + // enforced server-side. Seed one live (active) row, then attempt a SECOND + // live row for the same user via the service-role client and assert it is + // rejected with Postgres unique_violation (23505) — the same rejection the + // webhook upsert catches in prod. No provider creds needed. + let fixture: IsolatedSubscription | null = null; + try { + fixture = await seedIsolatedSubscription('active', { + provider: 'stripe', + }); + const admin = getAdminClient(); + test.skip( + !fixture || !admin, + 'Admin client unavailable to seed subscription' + ); + if (!fixture || !admin) return; + + const { error } = await admin.from('subscriptions').insert({ + template_user_id: fixture.user.id, + provider: 'paypal', + provider_subscription_id: `iso_dup_${Date.now()}`, + customer_email: fixture.user.email, + plan_amount: 999, + plan_interval: 'month', + status: 'active', // second LIVE row for the same user → must be rejected + failed_payment_count: 0, + }); + + expect(error).not.toBeNull(); + // Postgres unique_violation, on the one-live-per-user partial index. + expect(error?.code).toBe('23505'); + expect(error?.message).toMatch(/idx_subscriptions_one_live_per_user/i); + } finally { + await deleteIsolatedSubscription(fixture); + } }); }); diff --git a/tests/e2e/utils/test-user-factory.ts b/tests/e2e/utils/test-user-factory.ts index c1278ed2..41dac3bf 100644 --- a/tests/e2e/utils/test-user-factory.ts +++ b/tests/e2e/utils/test-user-factory.ts @@ -1982,6 +1982,145 @@ export async function deleteIsolatedGroup( console.log('✓ Isolated group torn down'); } +/** A live subscription status the dup-guard treats as occupying the one slot. */ +export type LiveSubscriptionStatus = 'active' | 'past_due' | 'grace_period'; +export type SubscriptionStatus = + | LiveSubscriptionStatus + | 'canceled' + | 'expired'; + +/** + * A throwaway user plus one seeded `subscriptions` row, with a session for + * browser injection — for subscription specs that assert UI driven by a real + * row (grace-period countdown, cancel, the one-live-per-user guard). Mirrors + * {@link IsolatedConnection}. + */ +export interface IsolatedSubscription { + user: TestUser; + session: InjectableSession; + subscriptionId: string; + /** The row's `grace_period_expires` (YYYY-MM-DD) when status is grace_period. */ + gracePeriodExpires?: string; +} + +/** + * Create a fully isolated throwaway user plus one `subscriptions` row at the + * given status. No payment provider is involved — the row is inserted directly + * with the service-role key (the same path the #5 webhooks use), so this needs + * NO Stripe/PayPal credentials. + * + * @param status subscription status to seed (default 'grace_period'). + * @param opts.provider 'stripe' (default) | 'paypal'. + * @param opts.graceDays days from today for `grace_period_expires` when the + * status is grace_period/past_due (default 5). + * + * Returns null if the admin client / anon key is unavailable. Tear down with + * {@link deleteIsolatedSubscription} — the subscriptions FK is NO ACTION (not + * cascade) and deleteTestUser does not touch subscriptions, so the row MUST be + * deleted before the user or the auth-user delete fails on the FK. + */ +export async function seedIsolatedSubscription( + status: SubscriptionStatus = 'grace_period', + opts?: { + provider?: 'stripe' | 'paypal'; + graceDays?: number; + prefix?: string; + } +): Promise { + const admin = getAdminClient(); + if (!admin) return null; + + const stamp = Date.now().toString().slice(-8); + const provider = opts?.provider ?? 'stripe'; + const prefix = opts?.prefix ?? 'iso-sub'; + + const created = await createKeyedUserWithSession(prefix, 'isos', stamp); + if (!created) return null; + + // grace_period_expires is a TEXT YYYY-MM-DD; only meaningful for the + // grace_period / past_due states the UI renders a countdown for. + const needsGrace = status === 'grace_period' || status === 'past_due'; + const graceDays = opts?.graceDays ?? 5; + const gracePeriodExpires = needsGrace + ? new Date(Date.now() + graceDays * 86_400_000).toISOString().split('T')[0] + : undefined; + + const { data: row, error } = await admin + .from('subscriptions') + .insert({ + template_user_id: created.user.id, + provider, + // Unique per row (subscriptions.provider_subscription_id is UNIQUE). + provider_subscription_id: `iso_sub_${provider}_${stamp}`, + customer_email: created.user.email, + plan_amount: 999, // $9.99, satisfies CHECK (>= 100) + plan_interval: 'month', + status, + failed_payment_count: needsGrace ? 1 : 0, + ...(gracePeriodExpires + ? { grace_period_expires: gracePeriodExpires } + : {}), + }) + .select('id') + .single(); + + if (error || !row) { + console.warn( + 'seedIsolatedSubscription: subscription insert failed:', + error?.message + ); + await deleteTestUser(created.user.id); + return null; + } + + console.log( + `✓ Isolated subscription ${row.id} (${status}; user ${created.user.id})` + ); + return { + user: created.user, + session: created.session, + subscriptionId: row.id as string, + gracePeriodExpires: gracePeriodExpires, + }; +} + +/** + * Tear down an isolated subscription. Deletes ALL of the user's subscription + * rows first (the FK is NO ACTION, so the auth-user delete would otherwise fail) + * then the throwaway user. Safe with null. + */ +export async function deleteIsolatedSubscription( + fixture: IsolatedSubscription | null +): Promise { + if (!fixture) return; + const admin = getAdminClient(); + if (admin) { + await admin + .from('subscriptions') + .delete() + .eq('template_user_id', fixture.user.id); + } + await deleteTestUser(fixture.user.id); + console.log('✓ Isolated subscription torn down'); +} + +/** + * Open a fresh browser context authenticated as the isolated subscription's + * user, landing on `/account/subscriptions`. Mirrors {@link openAsViewer}. + */ +export async function openSubscriptionsAs( + browser: Browser, + fixture: IsolatedSubscription +): Promise { + const basePath = process.env.NEXT_PUBLIC_BASE_PATH || ''; + const opened = await openAuthedPage(browser, fixture.session); + await opened.page.goto(`${basePath}/account/subscriptions`, { + waitUntil: 'domcontentloaded', + }); + await dismissCookieBanner(opened.page); + return opened; +} + /** * Scroll a message thread to the bottom so virtual-scrolled messages * at the end of the list are rendered in the DOM.