import { randomUUID } from 'node:crypto' import { expect, test } from '@playwright/test' import { createClient, type SupabaseClient } from '@supabase/supabase-js' import { PAYPLE_PENDING_CHECKOUT_STORAGE_KEY } from '../src/components/billing/payple-client' const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL const serviceRoleKey = process.env.E2E_SUPABASE_SERVICE_ROLE_KEY const paypleClientKey = process.env.NEXT_PUBLIC_PAYPLE_CLIENT_KEY const hasFixtureAccess = Boolean(supabaseUrl && serviceRoleKey && paypleClientKey) test.describe('Billing provider and Payple DOM flow', () => { test.skip(!hasFixtureAccess, 'Local Supabase and Payple E2E configuration are required') test.describe.configure({ mode: 'serial' }) test.setTimeout(90_000) const runId = randomUUID() const email = `billing-${runId}@example.test` const password = `Billing-${runId}-Aa1!` let admin: SupabaseClient let userId = '' test.beforeAll(async () => { admin = createClient(supabaseUrl!, serviceRoleKey!, { auth: { autoRefreshToken: false, persistSession: false } }) const created = await admin.auth.admin.createUser({ email, password, email_confirm: true }) if (created.error || !created.data.user) throw created.error ?? new Error('Billing fixture user was not created') userId = created.data.user.id const { error } = await admin.from('subscriptions').upsert({ user_id: userId, tier: 'free', status: 'active', provider: 'none', payment_provider: 'none', auto_renewing: false, cancel_at: null, current_period_start: null, current_period_end: null }, { onConflict: 'user_id' }) if (error) throw error }) test.afterAll(async () => { if (userId) await admin.auth.admin.deleteUser(userId) }) test('renders real subscription and fails closed before refreshing successful checkout/cancellation', async ({ page }) => { let checkoutMode: 'server-error' | 'success' = 'server-error' let checkoutCalls = 0 const idempotencyKeys: string[] = [] let manageCalls = 0 await page.route('https://democpay.payple.kr/js/v1/payment.js', async (route) => { await route.fulfill({ status: 200, contentType: 'application/javascript', body: ` window.__paypleMode = 'cancel'; window.PaypleCpayAuthCheck = function (request) { window.__lastPayplePayerNo = request.PCD_PAYER_NO; if (window.__paypleMode === 'throw') throw new Error('mock sdk failure'); if (window.__paypleMode === 'cancel') { setTimeout(function () { request.callbackFunction({ PCD_PAY_RST: 'error', PCD_PAY_MSG: '사용자 취소' }); }, 0); return; } setTimeout(function () { request.callbackFunction({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'billing-key-e2e', PCD_PAY_CARDNAME: 'TEST', PCD_PAY_CARDNUM: '1234-****-****-5678' }); }, 0); }; ` }) }) await page.route('**/functions/v1/payple-checkout', async (route) => { checkoutCalls += 1 const requestBody = route.request().postDataJSON() as { idempotency_key?: unknown } if (typeof requestBody.idempotency_key === 'string') idempotencyKeys.push(requestBody.idempotency_key) if (checkoutMode === 'server-error') { await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ error: 'mock_failure' }) }) return } const periodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() const { error } = await admin.from('subscriptions').update({ tier: 'pro', status: 'active', provider: 'payple', payment_provider: 'payple', auto_renewing: true, cancel_at: null, current_period_start: new Date().toISOString(), current_period_end: periodEnd }).eq('user_id', userId) if (error) throw error await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, tier: 'pro', order_id: `D3RO-${runId}`, amount: 9900 }) }) }) await page.route('**/functions/v1/payple-manage', async (route) => { manageCalls += 1 const cancelAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() const { error } = await admin.from('subscriptions').update({ status: 'canceled', auto_renewing: false, cancel_at: cancelAt, current_period_end: cancelAt }).eq('user_id', userId) if (error) throw error await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ success: true, cancel_at: cancelAt }) }) }) await page.goto('/login') await page.getByPlaceholder('user@studio.com').fill(email) await page.getByPlaceholder('••••••••').fill(password) await page.getByRole('button', { name: '로그인', exact: true }).click() await page.waitForURL(/\/dashboard/, { timeout: 15_000 }) await page.goto('/billing') await expect(page.getByTestId('billing-current-tier')).toHaveText('FREE') await expect(page.getByTestId('billing-current-provider')).toContainText('없음') await expect(page.getByTestId('billing-account')).toContainText(email) const proPlan = page.getByTestId('billing-plan-pro') await expect(proPlan.getByTestId('payple-upgrade-pro')).toBeEnabled() await expect(page.getByTestId('billing-plan-pro_plus')).toBeVisible() await proPlan.getByLabel('Stripe 해외 카드').click() await expect(proPlan.getByTestId('stripe-upgrade-pro')).toBeVisible() await proPlan.getByLabel('Payple 국내 카드').click() const stripePeriodEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString() const { error: stripeFixtureError } = await admin.from('subscriptions').update({ tier: 'pro', status: 'active', provider: 'stripe', payment_provider: 'stripe', auto_renewing: true, cancel_at: null, current_period_start: new Date().toISOString(), current_period_end: stripePeriodEnd }).eq('user_id', userId) if (stripeFixtureError) throw stripeFixtureError await page.reload() await expect(page.getByTestId('billing-current-provider')).toContainText('Stripe') await expect(page.getByTestId('stripe-portal-open')).toBeVisible() await expect(page.getByTestId('billing-checkout-pro')).toHaveCount(0) const { error: freeFixtureError } = await admin.from('subscriptions').update({ tier: 'free', status: 'active', provider: 'none', payment_provider: 'none', auto_renewing: false, cancel_at: null, current_period_start: null, current_period_end: null }).eq('user_id', userId) if (freeFixtureError) throw freeFixtureError await page.reload() await expect(page.getByTestId('billing-current-tier')).toHaveText('FREE') await expect(proPlan.getByTestId('payple-upgrade-pro')).toBeEnabled() await page.evaluate(() => Reflect.set(window, '__paypleMode', 'cancel')) await proPlan.getByTestId('payple-upgrade-pro').click() await expect(proPlan.getByTestId('payple-error-pro')).toContainText('사용자 취소') expect(checkoutCalls).toBe(0) await page.evaluate(() => Reflect.set(window, '__paypleMode', 'throw')) await proPlan.getByTestId('payple-upgrade-pro').click() await expect(proPlan.getByTestId('payple-error-pro')).toContainText('mock sdk failure') expect(checkoutCalls).toBe(0) await page.evaluate(() => Reflect.set(window, '__paypleMode', 'success')) await proPlan.getByTestId('payple-upgrade-pro').click() await expect(proPlan.getByTestId('payple-error-pro')).toContainText('결제 서버에서 요청을 완료하지 못했습니다') expect(checkoutCalls).toBe(1) expect(idempotencyKeys[0]).toMatch(/^payple-checkout:[0-9a-f-]{36}$/) await expect.poll(async () => { const { data } = await admin.from('subscriptions').select('tier').eq('user_id', userId).single() return data?.tier }).toBe('free') checkoutMode = 'success' await proPlan.getByTestId('payple-upgrade-pro').click() await expect(proPlan.getByTestId('payple-success-pro')).toBeVisible() expect(checkoutCalls).toBe(2) expect(idempotencyKeys[1]).toBe(idempotencyKeys[0]) await expect.poll(() => page.evaluate((key) => window.sessionStorage.getItem(key), PAYPLE_PENDING_CHECKOUT_STORAGE_KEY)).toBeNull() const payerNumber = await page.evaluate(() => Reflect.get(window, '__lastPayplePayerNo')) expect(payerNumber).toMatch(/^\d{18}$/) expect(payerNumber).not.toBe(userId) await expect(page.getByTestId('billing-current-tier')).toHaveText('PRO', { timeout: 10_000 }) await expect(page.getByTestId('billing-current-provider')).toContainText('Payple') await expect(page.getByTestId('payple-manage-open')).toBeEnabled() await page.getByTestId('payple-manage-open').click() await page.getByTestId('payple-manage-confirm').click() await expect(page.getByTestId('payple-manage-success')).toBeVisible() expect(manageCalls).toBe(1) await expect(page.getByTestId('billing-cancel-at')).toBeVisible({ timeout: 10_000 }) await expect(page.getByText('자동 갱신이 해지되었습니다.')).toBeVisible() await expect(page.getByTestId('payple-manage-open')).toHaveCount(0) }) })