d3ro-voice/apps/web/e2e/billing.spec.ts
2026-08-29 18:33:45 +09:00

219 lines
9.4 KiB
TypeScript

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)
})
})