219 lines
9.4 KiB
TypeScript
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)
|
|
})
|
|
})
|