feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
74
apps/web/e2e/billing-catalog.spec.ts
Normal file
74
apps/web/e2e/billing-catalog.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import {
|
||||
formatBillingPrice,
|
||||
formatPlanCatalogPrice,
|
||||
parseBillingCatalog,
|
||||
} from '../src/lib/billing-catalog'
|
||||
|
||||
const catalogPayload = {
|
||||
schema_version: '1',
|
||||
plans: [
|
||||
{
|
||||
tier: 'pro',
|
||||
prices: [
|
||||
{ provider: 'payple', unit_amount: 9900, currency: 'KRW', interval: 'month', interval_count: 1 },
|
||||
{ provider: 'stripe', unit_amount: 999, currency: 'USD', interval: 'month', interval_count: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
tier: 'pro_plus',
|
||||
prices: [
|
||||
{ provider: 'payple', unit_amount: 29900, currency: 'KRW', interval: 'month', interval_count: 1 },
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
test.describe('billing catalog strict client contract', () => {
|
||||
test('parses provider prices and formats minor currency units', () => {
|
||||
const catalog = parseBillingCatalog(catalogPayload)
|
||||
expect(catalog).not.toBeNull()
|
||||
expect(formatBillingPrice(catalog!.plans.pro[0])).toContain('9,900')
|
||||
expect(formatBillingPrice(catalog!.plans.pro[1])).toContain('9.99')
|
||||
expect(formatPlanCatalogPrice('pro', catalog)).toContain('Payple')
|
||||
expect(formatPlanCatalogPrice('pro', catalog)).toContain('Stripe')
|
||||
expect(formatPlanCatalogPrice('free', catalog)).toBe('무료')
|
||||
})
|
||||
|
||||
test('rejects duplicate tiers/providers, malformed amounts, and unknown values', () => {
|
||||
const invalid = [
|
||||
null,
|
||||
{},
|
||||
{ ...catalogPayload, schema_version: '2' },
|
||||
{ ...catalogPayload, plans: [catalogPayload.plans[0], catalogPayload.plans[0]] },
|
||||
{
|
||||
...catalogPayload,
|
||||
plans: [{
|
||||
tier: 'pro',
|
||||
prices: [catalogPayload.plans[0].prices[0], catalogPayload.plans[0].prices[0]],
|
||||
}, catalogPayload.plans[1]],
|
||||
},
|
||||
{
|
||||
...catalogPayload,
|
||||
plans: [{ tier: 'pro', prices: [{ ...catalogPayload.plans[0].prices[0], unit_amount: 0 }] }, catalogPayload.plans[1]],
|
||||
},
|
||||
{
|
||||
...catalogPayload,
|
||||
plans: [{ tier: 'pro', prices: [{ ...catalogPayload.plans[0].prices[0], currency: 'KRW<script>' }] }, catalogPayload.plans[1]],
|
||||
},
|
||||
]
|
||||
for (const value of invalid) expect(parseBillingCatalog(value)).toBeNull()
|
||||
})
|
||||
|
||||
test('keeps an unavailable provider absent instead of inventing a price', () => {
|
||||
const catalog = parseBillingCatalog({
|
||||
schema_version: '1',
|
||||
plans: [
|
||||
{ tier: 'pro', prices: [] },
|
||||
{ tier: 'pro_plus', prices: [] },
|
||||
],
|
||||
})
|
||||
expect(catalog).not.toBeNull()
|
||||
expect(formatPlanCatalogPrice('pro', catalog)).toBeNull()
|
||||
})
|
||||
})
|
||||
219
apps/web/e2e/billing.spec.ts
Normal file
219
apps/web/e2e/billing.spec.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
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)
|
||||
})
|
||||
})
|
||||
362
apps/web/e2e/dashboard-dictionary-commands.spec.ts
Normal file
362
apps/web/e2e/dashboard-dictionary-commands.spec.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
const serviceRoleKey = process.env.E2E_SUPABASE_SERVICE_ROLE_KEY
|
||||
const hasSupabaseFixtureAccess = Boolean(supabaseUrl && anonKey && serviceRoleKey)
|
||||
|
||||
test.describe('Dashboard, dictionary and command Supabase parity', () => {
|
||||
test.skip(!hasSupabaseFixtureAccess, 'Local Supabase fixture credentials are required')
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
test.setTimeout(90_000)
|
||||
|
||||
const runId = randomUUID()
|
||||
const ownerEmail = `web-parity-owner-${runId}@d3ro.test`
|
||||
const otherEmail = `web-parity-other-${runId}@d3ro.test`
|
||||
const password = `WebParity-${runId}-Aa1!`
|
||||
const recentTitle = `DASHBOARD RECENT ${runId}`
|
||||
const foreignHistoryTitle = `FOREIGN HISTORY ${runId}`
|
||||
const foreignDictionaryWord = `FOREIGN-DICTIONARY-${runId}`
|
||||
const needleWord = `NEEDLE-${runId}`
|
||||
const oldestWord = `OLDEST-${runId}`
|
||||
const conflictWord = `CONFLICT-${runId}`
|
||||
const remoteConflictWord = `REMOTE-CONFLICT-${runId}`
|
||||
const createdWord = `CREATED-${runId}`
|
||||
const historyIds = Array.from({ length: 5 }, () => randomUUID())
|
||||
const dictionaryIds = Array.from({ length: 22 }, () => randomUUID())
|
||||
const conflictId = dictionaryIds[2]
|
||||
const foreignInstructionId = randomUUID()
|
||||
const foreignInstructionName = `FOREIGN-INSTRUCTION-${runId}`
|
||||
|
||||
let admin: SupabaseClient
|
||||
let ownerId = ''
|
||||
let otherId = ''
|
||||
|
||||
async function login(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.goto('/login')
|
||||
await page.getByPlaceholder('user@studio.com').fill(ownerEmail)
|
||||
await page.getByPlaceholder('••••••••').fill(password)
|
||||
await page.getByRole('button', { name: '로그인', exact: true }).click()
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 15_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
admin = createClient(supabaseUrl!, serviceRoleKey!, {
|
||||
auth: { autoRefreshToken: false, persistSession: false }
|
||||
})
|
||||
|
||||
const ownerResult = await admin.auth.admin.createUser({ email: ownerEmail, password, email_confirm: true })
|
||||
if (ownerResult.error || !ownerResult.data.user) throw ownerResult.error ?? new Error('Owner fixture was not created')
|
||||
ownerId = ownerResult.data.user.id
|
||||
|
||||
const otherResult = await admin.auth.admin.createUser({ email: otherEmail, password, email_confirm: true })
|
||||
if (otherResult.error || !otherResult.data.user) throw otherResult.error ?? new Error('Other fixture was not created')
|
||||
otherId = otherResult.data.user.id
|
||||
|
||||
const now = Date.now()
|
||||
const { error: historyError } = await admin.from('history').insert([
|
||||
{
|
||||
id: historyIds[0], user_id: ownerId, title: recentTitle,
|
||||
original_text: `Owner current transcript ${runId}`, duration: 61, word_count: 3,
|
||||
status: 'completed', created_at: new Date(now).toISOString()
|
||||
},
|
||||
{
|
||||
id: historyIds[1], user_id: ownerId, title: `YESTERDAY ${runId}`,
|
||||
original_text: `Owner yesterday transcript ${runId}`, duration: 62, word_count: 4,
|
||||
status: 'completed', created_at: new Date(now - 24 * 60 * 60 * 1_000).toISOString()
|
||||
},
|
||||
{
|
||||
id: historyIds[2], user_id: ownerId, title: `OLDER ${runId}`,
|
||||
original_text: `Owner older transcript ${runId}`, duration: 63, word_count: 5,
|
||||
status: 'completed', created_at: new Date(now - 2 * 24 * 60 * 60 * 1_000).toISOString()
|
||||
},
|
||||
{
|
||||
id: historyIds[3], user_id: ownerId, title: `ERROR ROW ${runId}`,
|
||||
original_text: `Excluded failed transcript ${runId}`, duration: 9_999, word_count: 9_999,
|
||||
status: 'error', error_code: 'fixture', created_at: new Date(now - 500).toISOString()
|
||||
},
|
||||
{
|
||||
id: historyIds[4], user_id: otherId, title: foreignHistoryTitle,
|
||||
original_text: foreignHistoryTitle, duration: 99, word_count: 99, status: 'completed',
|
||||
created_at: new Date(now - 1_000).toISOString()
|
||||
}
|
||||
])
|
||||
if (historyError) throw historyError
|
||||
|
||||
const { error: subscriptionError } = await admin
|
||||
.from('subscriptions')
|
||||
.update({ tier: 'pro', provider: 'admin', status: 'active', overage_credits: 7 })
|
||||
.eq('user_id', ownerId)
|
||||
if (subscriptionError) throw subscriptionError
|
||||
|
||||
const dictionaryRows = dictionaryIds.map((id, index) => ({
|
||||
id,
|
||||
user_id: ownerId,
|
||||
word: index === 0 ? needleWord : index === 2 ? conflictWord : index === 21 ? oldestWord : `WORD-${index}-${runId}`,
|
||||
pronunciation: index === 0 ? `니들-${runId}` : null,
|
||||
category: index % 2 === 0 ? 'technical' : 'user',
|
||||
usage_count: index,
|
||||
updated_at: new Date(now - index * 60_000).toISOString()
|
||||
}))
|
||||
const { error: dictionaryError } = await admin.from('dictionary').insert(dictionaryRows)
|
||||
if (dictionaryError) throw dictionaryError
|
||||
const { error: foreignDictionaryError } = await admin.from('dictionary').insert({
|
||||
user_id: otherId,
|
||||
word: foreignDictionaryWord,
|
||||
category: 'user'
|
||||
})
|
||||
if (foreignDictionaryError) throw foreignDictionaryError
|
||||
const { error: foreignInstructionError } = await admin.from('custom_instructions').insert({
|
||||
id: foreignInstructionId,
|
||||
user_id: otherId,
|
||||
name: foreignInstructionName,
|
||||
description: 'Must remain hidden by RLS',
|
||||
prompt: 'Never visible {{text}}',
|
||||
sort_order: 100
|
||||
})
|
||||
if (foreignInstructionError) throw foreignInstructionError
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (ownerId) await admin.auth.admin.deleteUser(ownerId)
|
||||
if (otherId) await admin.auth.admin.deleteUser(otherId)
|
||||
})
|
||||
|
||||
test('dashboard renders exact RPC aggregates and subscription, then recovers from an RPC failure', async ({ page }) => {
|
||||
await login(page)
|
||||
await expect(page.getByText('내 음성 작업 현황')).toBeVisible()
|
||||
await expect(page.getByTestId('dashboard-stat-전체 단어 수')).toContainText('12')
|
||||
await expect(page.getByTestId('dashboard-subscription')).toContainText('PRO')
|
||||
await expect(page.getByTestId('dashboard-subscription')).toContainText('관리자 부여')
|
||||
await expect(page.getByTestId('dashboard-subscription')).toContainText('7')
|
||||
await expect(page.getByText(recentTitle)).toBeVisible()
|
||||
await expect(page.getByText(foreignHistoryTitle)).toHaveCount(0)
|
||||
|
||||
await page.route('**/rest/v1/rpc/mobile_dashboard_stats', async (route) => {
|
||||
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ message: 'fixture failure' }) })
|
||||
})
|
||||
await page.reload()
|
||||
await expect(page.getByText('대시보드 데이터를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.')).toBeVisible()
|
||||
await page.unroute('**/rest/v1/rpc/mobile_dashboard_stats')
|
||||
await page.getByRole('button', { name: '다시 시도' }).click()
|
||||
await expect(page.getByText(recentTitle)).toBeVisible()
|
||||
})
|
||||
|
||||
test('dictionary uses RLS CRUD, filters, pagination, duplicate protection and optimistic rollback', async ({ page }) => {
|
||||
await login(page)
|
||||
await page.goto('/dictionary')
|
||||
await expect(page.getByText('CUSTOM DICTIONARY')).toBeVisible()
|
||||
await expect(page.getByText(foreignDictionaryWord)).toHaveCount(0)
|
||||
await expect(page.getByText(oldestWord)).toHaveCount(0)
|
||||
|
||||
await page.getByRole('button', { name: '더 불러오기' }).click()
|
||||
await expect(page.getByText(oldestWord)).toBeVisible()
|
||||
|
||||
await page.getByLabel('커스텀 사전 검색').fill('NEEDLE')
|
||||
await expect(page.getByText(needleWord)).toBeVisible()
|
||||
await expect(page.getByText(oldestWord)).toHaveCount(0)
|
||||
await page.getByLabel('커스텀 사전 검색').fill('')
|
||||
await page.getByText('기술', { exact: true }).first().click()
|
||||
await expect(page.getByText(needleWord)).toBeVisible()
|
||||
await expect(page.getByText(`WORD-1-${runId}`)).toHaveCount(0)
|
||||
await page.getByText('전체', { exact: true }).click()
|
||||
|
||||
await page.getByRole('button', { name: '단어 추가' }).click()
|
||||
await page.getByRole('textbox', { name: '단어', exact: true }).fill(createdWord)
|
||||
await page.getByLabel('발음 (선택)').fill(`크리에이티드-${runId}`)
|
||||
await page.getByRole('button', { name: '저장' }).click()
|
||||
await expect(page.getByText(createdWord)).toBeVisible()
|
||||
await expect.poll(async () => {
|
||||
const { count } = await admin.from('dictionary').select('id', { count: 'exact', head: true }).eq('user_id', ownerId).eq('word', createdWord)
|
||||
return count
|
||||
}).toBe(1)
|
||||
|
||||
await page.getByRole('button', { name: '단어 추가' }).click()
|
||||
await page.getByRole('textbox', { name: '단어', exact: true }).fill(createdWord.toLocaleLowerCase())
|
||||
await page.getByRole('button', { name: '저장' }).click()
|
||||
await expect(page.getByText('같은 분류에 동일한 단어가 이미 있습니다.')).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: `${conflictWord} 편집` }).click()
|
||||
const { error: remoteUpdateError } = await admin.from('dictionary').update({ word: remoteConflictWord }).eq('id', conflictId)
|
||||
if (remoteUpdateError) throw remoteUpdateError
|
||||
await page.getByRole('textbox', { name: '단어', exact: true }).fill(`ATTEMPTED-${runId}`)
|
||||
await page.getByRole('button', { name: '저장' }).click()
|
||||
await expect(page.getByText('다른 기기에서 변경된 단어입니다. 목록을 새로고침한 뒤 다시 시도해 주세요.')).toBeVisible()
|
||||
await expect(page.getByText(conflictWord)).toBeVisible()
|
||||
await page.getByRole('button', { name: '새로고침' }).click()
|
||||
await expect(page.getByText(remoteConflictWord)).toBeVisible()
|
||||
|
||||
await page.route('**/rest/v1/dictionary*', async (route) => {
|
||||
if (route.request().method() === 'DELETE') {
|
||||
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ message: 'fixture delete failure' }) })
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await page.getByRole('button', { name: `${createdWord} 삭제` }).click()
|
||||
await expect(page.getByText(createdWord)).toBeVisible()
|
||||
await expect(page.getByText('사전을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.')).toBeVisible()
|
||||
await page.unroute('**/rest/v1/dictionary*')
|
||||
const { count: retainedCount } = await admin.from('dictionary').select('id', { count: 'exact', head: true }).eq('user_id', ownerId).eq('word', createdWord)
|
||||
expect(retainedCount).toBe(1)
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await page.getByRole('button', { name: `${createdWord} 삭제` }).click()
|
||||
await expect(page.getByText(createdWord)).toHaveCount(0)
|
||||
await expect.poll(async () => {
|
||||
const { count } = await admin.from('dictionary').select('id', { count: 'exact', head: true }).eq('user_id', ownerId).eq('word', createdWord)
|
||||
return count
|
||||
}).toBe(0)
|
||||
|
||||
const ownerClient = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false, autoRefreshToken: false } })
|
||||
const signIn = await ownerClient.auth.signInWithPassword({ email: ownerEmail, password })
|
||||
if (signIn.error) throw signIn.error
|
||||
const foreignRead = await ownerClient.from('dictionary').select('id').eq('word', foreignDictionaryWord)
|
||||
expect(foreignRead.error).toBeNull()
|
||||
expect(foreignRead.data).toEqual([])
|
||||
const foreignWrite = await ownerClient.from('dictionary').update({ word: `STOLEN-${runId}` }).eq('user_id', otherId).select('id')
|
||||
expect(foreignWrite.error).toBeNull()
|
||||
expect(foreignWrite.data).toEqual([])
|
||||
await ownerClient.auth.signOut({ scope: 'local' })
|
||||
})
|
||||
|
||||
test('commands sync immutable built-ins, custom CRUD/order/CAS/active state and the real llm-proxy', async ({ page }) => {
|
||||
await login(page)
|
||||
const bootstrapClient = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false, autoRefreshToken: false } })
|
||||
const bootstrapSignIn = await bootstrapClient.auth.signInWithPassword({ email: ownerEmail, password })
|
||||
if (bootstrapSignIn.error) throw bootstrapSignIn.error
|
||||
const [bootstrapA, bootstrapB] = await Promise.all([
|
||||
bootstrapClient.rpc('bootstrap_custom_instructions'),
|
||||
bootstrapClient.rpc('bootstrap_custom_instructions')
|
||||
])
|
||||
expect(bootstrapA.error).toBeNull()
|
||||
expect(bootstrapB.error).toBeNull()
|
||||
expect(bootstrapA.data).toHaveLength(4)
|
||||
expect(bootstrapB.data).toHaveLength(4)
|
||||
await bootstrapClient.auth.signOut({ scope: 'local' })
|
||||
await page.goto('/commands')
|
||||
await expect(page.getByText('SYNCED COMMANDS (4)')).toBeVisible()
|
||||
await expect(page.getByText('Translate to English', { exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByText('BUILT-IN · READ ONLY').first()).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: 'Translate to English 편집' })).toHaveCount(0)
|
||||
await expect(page.getByText(foreignInstructionName)).toHaveCount(0)
|
||||
|
||||
const firstCustomName = `CUSTOM-A-${runId}`
|
||||
const secondCustomName = `CUSTOM-B-${runId}`
|
||||
const editedSecondName = `CUSTOM-B-EDITED-${runId}`
|
||||
const remoteFirstName = `CUSTOM-A-REMOTE-${runId}`
|
||||
const attemptedFirstName = `CUSTOM-A-ATTEMPTED-${runId}`
|
||||
const firstPrompt = `FIRST PROMPT ${runId} {{text}}`
|
||||
|
||||
await page.getByRole('button', { name: '사용자 명령 추가' }).click()
|
||||
await page.getByLabel('명령 이름').fill(firstCustomName)
|
||||
await page.getByLabel('명령 설명').fill('첫 번째 사용자 명령')
|
||||
await page.getByLabel('명령 프롬프트').fill(firstPrompt)
|
||||
await page.getByRole('button', { name: '저장' }).click()
|
||||
await expect(page.getByText(firstCustomName, { exact: true }).first()).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: '사용자 명령 추가' }).click()
|
||||
await page.getByLabel('명령 이름').fill(secondCustomName)
|
||||
await page.getByLabel('명령 설명').fill('두 번째 사용자 명령')
|
||||
await page.getByLabel('명령 프롬프트').fill(`SECOND PROMPT ${runId}`)
|
||||
await page.getByRole('button', { name: '저장' }).click()
|
||||
await expect(page.getByText(secondCustomName)).toBeVisible()
|
||||
|
||||
await expect.poll(async () => {
|
||||
const { data } = await admin.from('custom_instructions').select('id,revision,sort_order').eq('user_id', ownerId).eq('name', firstCustomName).maybeSingle()
|
||||
return data
|
||||
}).not.toBeNull()
|
||||
const { data: firstInstruction } = await admin.from('custom_instructions').select('id,revision,sort_order').eq('user_id', ownerId).eq('name', firstCustomName).single()
|
||||
const { data: secondInstruction } = await admin.from('custom_instructions').select('id,revision,sort_order').eq('user_id', ownerId).eq('name', secondCustomName).single()
|
||||
if (!firstInstruction || !secondInstruction) throw new Error('Custom command fixtures were not created')
|
||||
|
||||
await page.getByRole('button', { name: `${firstCustomName} 명령 활성화` }).click()
|
||||
await expect(page.getByText('ACTIVE SYNCED INSTRUCTION').locator('..')).toContainText(firstCustomName)
|
||||
await expect.poll(async () => {
|
||||
const { data } = await admin.from('user_settings').select('active_instruction_id').eq('user_id', ownerId).single()
|
||||
return data?.active_instruction_id
|
||||
}).toBe(firstInstruction.id)
|
||||
await page.reload()
|
||||
await expect(page.getByText('ACTIVE SYNCED INSTRUCTION').locator('..')).toContainText(firstCustomName)
|
||||
|
||||
await page.getByRole('button', { name: `${secondCustomName} 편집` }).click()
|
||||
await page.getByLabel('명령 이름').fill(editedSecondName)
|
||||
await page.getByRole('button', { name: '저장' }).click()
|
||||
await expect(page.getByText(editedSecondName)).toBeVisible()
|
||||
await expect.poll(async () => {
|
||||
const { data } = await admin.from('custom_instructions').select('revision').eq('id', secondInstruction.id).single()
|
||||
return data?.revision
|
||||
}).toBe(2)
|
||||
|
||||
await page.getByRole('button', { name: `${firstCustomName} 아래로` }).click()
|
||||
await expect.poll(async () => {
|
||||
const { data } = await admin.from('custom_instructions').select('sort_order').eq('id', firstInstruction.id).single()
|
||||
return Number(data?.sort_order)
|
||||
}).toBeGreaterThan(Number(secondInstruction.sort_order))
|
||||
const customCardTexts = await page.locator('[data-testid^="command-card-"]').allTextContents()
|
||||
expect(customCardTexts.findIndex((text) => text.includes(editedSecondName))).toBeLessThan(customCardTexts.findIndex((text) => text.includes(firstCustomName)))
|
||||
|
||||
await page.getByRole('button', { name: `${firstCustomName} 편집` }).click()
|
||||
const { error: remoteInstructionError } = await admin
|
||||
.from('custom_instructions')
|
||||
.update({ name: remoteFirstName, prompt: `REMOTE PROMPT ${runId} {{text}}` })
|
||||
.eq('id', firstInstruction.id)
|
||||
if (remoteInstructionError) throw remoteInstructionError
|
||||
await page.getByLabel('명령 이름').fill(attemptedFirstName)
|
||||
await page.getByRole('button', { name: '저장' }).click()
|
||||
await expect(page.getByText('다른 기기에서 변경된 명령입니다. 새로고침한 뒤 다시 시도해 주세요.')).toBeVisible()
|
||||
await expect(page.getByText(firstCustomName, { exact: true }).first()).toBeVisible()
|
||||
await page.getByRole('button', { name: '새로고침' }).click()
|
||||
await expect(page.getByText(remoteFirstName, { exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByText('ACTIVE SYNCED INSTRUCTION').locator('..')).toContainText(remoteFirstName)
|
||||
|
||||
const marker = `COMMAND INPUT ${runId}`
|
||||
await page.getByLabel('명령 테스트 입력').fill(marker)
|
||||
const requestPromise = page.waitForRequest((request) => request.url().endsWith('/functions/v1/llm-proxy') && request.method() === 'POST')
|
||||
const responsePromise = page.waitForResponse((response) => response.url().endsWith('/functions/v1/llm-proxy'))
|
||||
await page.getByRole('button', { name: '실행', exact: true }).click()
|
||||
const [request, response] = await Promise.all([requestPromise, responsePromise])
|
||||
const body = request.postDataJSON() as { messages?: Array<{ content?: string }>; stream?: boolean }
|
||||
expect(body.stream).toBe(false)
|
||||
expect(body.messages?.[0]?.content).toContain(`REMOTE PROMPT ${runId}`)
|
||||
expect(body.messages?.[0]?.content).toContain(marker)
|
||||
|
||||
if (response.ok()) {
|
||||
await expect(page.getByTestId('command-output')).not.toBeEmpty()
|
||||
await expect(page.getByTestId('command-error')).toHaveCount(0)
|
||||
} else {
|
||||
expect(response.status()).toBeGreaterThanOrEqual(400)
|
||||
await expect(page.getByTestId('command-error')).toBeVisible()
|
||||
await expect(page.getByTestId('command-output')).toHaveCount(0)
|
||||
}
|
||||
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await page.getByRole('button', { name: `${editedSecondName} 삭제` }).click()
|
||||
await expect(page.getByText(editedSecondName)).toHaveCount(0)
|
||||
await expect.poll(async () => {
|
||||
const { count } = await admin.from('custom_instructions').select('id', { count: 'exact', head: true }).eq('id', secondInstruction.id)
|
||||
return count
|
||||
}).toBe(0)
|
||||
|
||||
const ownerClient = createClient(supabaseUrl!, anonKey!, { auth: { persistSession: false, autoRefreshToken: false } })
|
||||
const signIn = await ownerClient.auth.signInWithPassword({ email: ownerEmail, password })
|
||||
if (signIn.error) throw signIn.error
|
||||
const foreignRead = await ownerClient.from('custom_instructions').select('id').eq('id', foreignInstructionId)
|
||||
expect(foreignRead.error).toBeNull()
|
||||
expect(foreignRead.data).toEqual([])
|
||||
const foreignActivation = await ownerClient.rpc('set_active_custom_instruction', { instruction_id: foreignInstructionId })
|
||||
expect(foreignActivation.error).not.toBeNull()
|
||||
const builtinWrite = await ownerClient
|
||||
.from('custom_instructions')
|
||||
.update({ name: `MUTATED-BUILTIN-${runId}` })
|
||||
.eq('user_id', ownerId)
|
||||
.eq('builtin_key', 'translate_en')
|
||||
.select('id')
|
||||
expect(builtinWrite.error).toBeNull()
|
||||
expect(builtinWrite.data).toEqual([])
|
||||
await ownerClient.auth.signOut({ scope: 'local' })
|
||||
})
|
||||
})
|
||||
172
apps/web/e2e/history.spec.ts
Normal file
172
apps/web/e2e/history.spec.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { randomUUID } from 'node:crypto'
|
||||
import { expect, test } from '@playwright/test'
|
||||
import { createClient, type SupabaseClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL
|
||||
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||
const serviceRoleKey = process.env.E2E_SUPABASE_SERVICE_ROLE_KEY
|
||||
const hasSupabaseFixtureAccess = Boolean(supabaseUrl && anonKey && serviceRoleKey)
|
||||
|
||||
test.describe('History Supabase SSOT', () => {
|
||||
test.skip(!hasSupabaseFixtureAccess, 'Local Supabase fixture credentials are required')
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
const runId = randomUUID()
|
||||
const ownerEmail = `history-owner-${runId}@example.test`
|
||||
const otherEmail = `history-other-${runId}@example.test`
|
||||
const password = `History-${runId}-Aa1!`
|
||||
const entryIds = Array.from({ length: 22 }, () => randomUUID())
|
||||
const favoriteId = entryIds[1]
|
||||
const favoriteTitle = `FAVORITE ${runId}`
|
||||
const searchId = entryIds[2]
|
||||
const searchTitle = `NEEDLE QUERY ${runId}`
|
||||
const favoriteRollbackId = entryIds[3]
|
||||
const deleteRollbackId = entryIds[4]
|
||||
const oldestId = entryIds[21]
|
||||
const oldestTitle = `OLDEST PAGE ${runId}`
|
||||
const foreignTitle = `FOREIGN SECRET ${runId}`
|
||||
|
||||
let admin: SupabaseClient
|
||||
let ownerId = ''
|
||||
let otherId = ''
|
||||
|
||||
test.beforeAll(async () => {
|
||||
admin = createClient(supabaseUrl!, serviceRoleKey!, {
|
||||
auth: { autoRefreshToken: false, persistSession: false }
|
||||
})
|
||||
|
||||
const ownerResult = await admin.auth.admin.createUser({
|
||||
email: ownerEmail,
|
||||
password,
|
||||
email_confirm: true
|
||||
})
|
||||
if (ownerResult.error || !ownerResult.data.user) throw ownerResult.error ?? new Error('Owner fixture was not created')
|
||||
ownerId = ownerResult.data.user.id
|
||||
|
||||
const otherResult = await admin.auth.admin.createUser({
|
||||
email: otherEmail,
|
||||
password,
|
||||
email_confirm: true
|
||||
})
|
||||
if (otherResult.error || !otherResult.data.user) throw otherResult.error ?? new Error('Other fixture was not created')
|
||||
otherId = otherResult.data.user.id
|
||||
|
||||
const now = Date.now()
|
||||
const rows = entryIds.map((id, index) => ({
|
||||
id,
|
||||
user_id: ownerId,
|
||||
title: id === favoriteId
|
||||
? favoriteTitle
|
||||
: id === searchId
|
||||
? searchTitle
|
||||
: id === oldestId
|
||||
? oldestTitle
|
||||
: `HISTORY ${index} ${runId}`,
|
||||
original_text: `Owner transcript ${index} ${runId}`,
|
||||
polished_text: index % 2 === 0 ? `Polished transcript ${index} ${runId}` : null,
|
||||
duration: 10 + index,
|
||||
word_count: 4,
|
||||
is_favorite: id === favoriteId,
|
||||
revision: 1,
|
||||
created_at: new Date(now - index * 60_000).toISOString(),
|
||||
updated_at: new Date(now - index * 60_000).toISOString()
|
||||
}))
|
||||
const { error: ownerSeedError } = await admin.from('history').insert(rows)
|
||||
if (ownerSeedError) throw ownerSeedError
|
||||
|
||||
const { error: foreignSeedError } = await admin.from('history').insert({
|
||||
user_id: otherId,
|
||||
title: foreignTitle,
|
||||
original_text: foreignTitle,
|
||||
duration: 1,
|
||||
word_count: 2
|
||||
})
|
||||
if (foreignSeedError) throw foreignSeedError
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
if (ownerId) await admin.auth.admin.deleteUser(ownerId)
|
||||
if (otherId) await admin.auth.admin.deleteUser(otherId)
|
||||
})
|
||||
|
||||
test('session, RLS, search, favorite, pagination, detail and mutation rollback work end-to-end', async ({ page }) => {
|
||||
await page.goto('/login')
|
||||
await page.getByPlaceholder('user@studio.com').fill(ownerEmail)
|
||||
await page.getByPlaceholder('••••••••').fill(password)
|
||||
await page.getByRole('button', { name: '로그인', exact: true }).click()
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 15_000 })
|
||||
|
||||
await page.goto('/history')
|
||||
await expect(page.getByText('TRANSCRIPTION HISTORY')).toBeVisible()
|
||||
await expect(page.getByText(foreignTitle)).toHaveCount(0)
|
||||
await expect(page.getByTestId(`history-card-${oldestId}`)).toHaveCount(0)
|
||||
|
||||
await page.getByLabel('전사 기록 검색').fill(foreignTitle)
|
||||
await expect(page.getByText('조건에 맞는 전사 기록이 없습니다.')).toBeVisible()
|
||||
await expect(page.getByText(foreignTitle)).toHaveCount(0)
|
||||
|
||||
await page.getByLabel('전사 기록 검색').fill('NEEDLE QUERY')
|
||||
await expect(page.getByText(searchTitle)).toBeVisible()
|
||||
await expect(page.getByTestId(`history-card-${favoriteId}`)).toHaveCount(0)
|
||||
|
||||
await page.getByLabel('전사 기록 검색').fill('')
|
||||
await page.getByText('FAVORITES', { exact: true }).click()
|
||||
await expect(page.getByText(favoriteTitle)).toBeVisible()
|
||||
await expect(page.getByTestId(`history-card-${searchId}`)).toHaveCount(0)
|
||||
|
||||
await page.getByText('ALL', { exact: true }).click()
|
||||
await expect(page.getByTestId(`history-card-${favoriteRollbackId}`)).toBeVisible()
|
||||
|
||||
await page.route('**/rest/v1/history*', async (route) => {
|
||||
if (route.request().method() === 'PATCH') {
|
||||
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ message: 'fixture update failure' }) })
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
const favoriteRollbackCard = page.getByTestId(`history-card-${favoriteRollbackId}`)
|
||||
await favoriteRollbackCard.getByRole('button', { name: '즐겨찾기', exact: true }).click()
|
||||
await expect(page.getByText('히스토리를 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.')).toBeVisible()
|
||||
await expect(favoriteRollbackCard.getByRole('button', { name: '즐겨찾기', exact: true })).toBeVisible()
|
||||
await page.unroute('**/rest/v1/history*')
|
||||
|
||||
await favoriteRollbackCard.getByRole('button', { name: '즐겨찾기', exact: true }).click()
|
||||
await expect(favoriteRollbackCard.getByRole('button', { name: '즐겨찾기 해제', exact: true })).toBeVisible()
|
||||
await expect.poll(async () => {
|
||||
const { data } = await admin.from('history').select('is_favorite, revision').eq('id', favoriteRollbackId).single()
|
||||
return data
|
||||
}).toEqual({ is_favorite: true, revision: 2 })
|
||||
|
||||
await page.route('**/rest/v1/history*', async (route) => {
|
||||
if (route.request().method() === 'DELETE') {
|
||||
await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ message: 'fixture delete failure' }) })
|
||||
} else {
|
||||
await route.continue()
|
||||
}
|
||||
})
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await page.getByTestId(`history-card-${deleteRollbackId}`).getByRole('button', { name: '전사 기록 삭제' }).click()
|
||||
await expect(page.getByTestId(`history-card-${deleteRollbackId}`)).toBeVisible()
|
||||
await page.unroute('**/rest/v1/history*')
|
||||
const { count: retainedAfterFailure } = await admin.from('history').select('id', { count: 'exact', head: true }).eq('id', deleteRollbackId)
|
||||
expect(retainedAfterFailure).toBe(1)
|
||||
|
||||
await page.getByText('더 불러오기').click()
|
||||
await expect(page.getByText(oldestTitle)).toBeVisible()
|
||||
|
||||
await page.getByText(favoriteTitle).click()
|
||||
await page.waitForURL(new RegExp(`/history/${favoriteId}$`))
|
||||
await expect(page.getByText('TRANSCRIPTION DETAIL')).toBeVisible()
|
||||
await expect(page.getByText(`Owner transcript 1 ${runId}`)).toBeVisible()
|
||||
|
||||
await page.getByRole('link', { name: '히스토리로 돌아가기' }).click()
|
||||
await expect(page.getByText('TRANSCRIPTION HISTORY')).toBeVisible()
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await page.getByTestId(`history-card-${deleteRollbackId}`).getByRole('button', { name: '전사 기록 삭제' }).click()
|
||||
await expect(page.getByTestId(`history-card-${deleteRollbackId}`)).toHaveCount(0)
|
||||
await expect.poll(async () => {
|
||||
const { count } = await admin.from('history').select('id', { count: 'exact', head: true }).eq('id', deleteRollbackId)
|
||||
return count
|
||||
}).toBe(0)
|
||||
})
|
||||
})
|
||||
236
apps/web/e2e/payple-checkout.spec.ts
Normal file
236
apps/web/e2e/payple-checkout.spec.ts
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import {
|
||||
assertSuccessfulCheckoutResponse,
|
||||
clearPaypleIdempotencyKey,
|
||||
createPaypleAuthRequest,
|
||||
getOrCreatePaypleIdempotencyKey,
|
||||
payplePayerNumber,
|
||||
PaypleClientError,
|
||||
runPaypleRegistration,
|
||||
type PaypleAuthRequest
|
||||
} from '../src/components/billing/payple-client'
|
||||
|
||||
const USER_ID = '11111111-2222-4333-8444-555555555555'
|
||||
const OTHER_USER_ID = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
|
||||
const EXPECTED_PAYER_NUMBER = '957057365735311784'
|
||||
|
||||
function baseOptions() {
|
||||
return {
|
||||
clientKey: 'test-client-key',
|
||||
userId: USER_ID,
|
||||
email: 'payer@example.test',
|
||||
tier: 'pro' as const,
|
||||
catalogPrice: {
|
||||
provider: 'payple' as const,
|
||||
unitAmount: 9900,
|
||||
currency: 'KRW',
|
||||
interval: 'month',
|
||||
intervalCount: 1
|
||||
},
|
||||
resultUrl: 'https://d3ro.chanpaca.net/billing'
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRequest(read: () => PaypleAuthRequest | null): Promise<PaypleAuthRequest> {
|
||||
await expect.poll(() => read()).not.toBeNull()
|
||||
return read()!
|
||||
}
|
||||
|
||||
test.describe('Payple payer number and fail-closed checkout', () => {
|
||||
test('matches the server SHA-256/BigInt vector and official 18-digit shape', async () => {
|
||||
await expect(payplePayerNumber(USER_ID)).resolves.toBe(EXPECTED_PAYER_NUMBER)
|
||||
await expect(payplePayerNumber(USER_ID)).resolves.toBe(EXPECTED_PAYER_NUMBER)
|
||||
await expect(payplePayerNumber(OTHER_USER_ID)).resolves.toBe('891654841482901925')
|
||||
await expect(payplePayerNumber(USER_ID)).resolves.toMatch(/^\d{18}$/)
|
||||
})
|
||||
|
||||
test('Chromium Web Crypto independently produces the same payer number', async ({ page }) => {
|
||||
await page.route('http://127.0.0.1/payple-crypto', async (route) => {
|
||||
await route.fulfill({ status: 200, contentType: 'text/html', body: '<!doctype html><title>Payple Crypto</title>' })
|
||||
})
|
||||
await page.goto('http://127.0.0.1/payple-crypto')
|
||||
const browserValue = await page.evaluate(async (userId) => {
|
||||
const bytes = new TextEncoder().encode(`d3ro-payple:${userId}`)
|
||||
const digest = await crypto.subtle.digest('SHA-256', bytes)
|
||||
const hex = Array.from(new Uint8Array(digest))
|
||||
.map((part) => part.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
return (BigInt(`0x${hex}`) % 1_000_000_000_000_000_000n)
|
||||
.toString()
|
||||
.padStart(18, '0')
|
||||
}, USER_ID)
|
||||
|
||||
expect(browserValue).toBe(EXPECTED_PAYER_NUMBER)
|
||||
})
|
||||
|
||||
test('rejects malformed user identifiers before opening the SDK', async () => {
|
||||
for (const userId of ['', 'not-a-uuid', `${USER_ID} `, '11111111-2222-0333-8444-555555555555']) {
|
||||
let opened = false
|
||||
await expect(runPaypleRegistration({
|
||||
...baseOptions(),
|
||||
userId,
|
||||
openSdk: () => { opened = true },
|
||||
chargeBillingKey: async () => undefined,
|
||||
timeoutMs: 50
|
||||
})).rejects.toMatchObject({ code: 'invalid_user' })
|
||||
expect(opened).toBe(false)
|
||||
}
|
||||
|
||||
await expect(payplePayerNumber(USER_ID, null)).rejects.toMatchObject({ code: 'crypto_unavailable' })
|
||||
await expect(runPaypleRegistration({
|
||||
...baseOptions(),
|
||||
clientKey: ' ',
|
||||
openSdk: () => { throw new Error('must not open') },
|
||||
chargeBillingKey: async () => { throw new Error('must not charge') },
|
||||
timeoutMs: 50
|
||||
})).rejects.toMatchObject({ code: 'not_configured' })
|
||||
})
|
||||
|
||||
test('never exposes the raw UUID in the Payple registration request', async () => {
|
||||
const request = await createPaypleAuthRequest(baseOptions())
|
||||
expect(request.PCD_PAYER_NO).toBe(EXPECTED_PAYER_NUMBER)
|
||||
expect(request.PCD_PAYER_NO).toMatch(/^\d{18}$/)
|
||||
expect(JSON.stringify(request)).not.toContain(USER_ID)
|
||||
})
|
||||
|
||||
test('rejects missing or tampered catalog price before opening Payple', async () => {
|
||||
for (const catalogPrice of [
|
||||
{ provider: 'stripe', unitAmount: 9900, currency: 'KRW', interval: 'month', intervalCount: 1 },
|
||||
{ provider: 'payple', unitAmount: 1, currency: 'USD', interval: 'month', intervalCount: 1 },
|
||||
{ provider: 'payple', unitAmount: 9900, currency: 'KRW', interval: 'year', intervalCount: 1 },
|
||||
{ provider: 'payple', unitAmount: 0, currency: 'KRW', interval: 'month', intervalCount: 1 }
|
||||
]) {
|
||||
let opened = false
|
||||
await expect(runPaypleRegistration({
|
||||
...baseOptions(),
|
||||
catalogPrice: catalogPrice as ReturnType<typeof baseOptions>['catalogPrice'],
|
||||
openSdk: () => { opened = true },
|
||||
chargeBillingKey: async () => undefined
|
||||
})).rejects.toMatchObject({ code: 'checkout_response_invalid' })
|
||||
expect(opened).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('reuses a pending key and rotates it only when payer or tier changes', () => {
|
||||
const values = new Map<string, string>()
|
||||
const storage = {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => { values.set(key, value) },
|
||||
removeItem: (key: string) => { values.delete(key) }
|
||||
}
|
||||
const uuids = [
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'33333333-3333-4333-8333-333333333333'
|
||||
]
|
||||
let index = 0
|
||||
const randomUuid = () => uuids[index++]!
|
||||
|
||||
const first = getOrCreatePaypleIdempotencyKey(storage, USER_ID, 'pro', randomUuid)
|
||||
const retry = getOrCreatePaypleIdempotencyKey(storage, USER_ID, 'pro', randomUuid)
|
||||
const changedTier = getOrCreatePaypleIdempotencyKey(storage, USER_ID, 'pro_plus', randomUuid)
|
||||
const changedUser = getOrCreatePaypleIdempotencyKey(storage, OTHER_USER_ID, 'pro_plus', randomUuid)
|
||||
|
||||
expect(retry).toBe(first)
|
||||
expect(changedTier).not.toBe(first)
|
||||
expect(changedUser).not.toBe(changedTier)
|
||||
clearPaypleIdempotencyKey(storage, USER_ID, 'pro', first)
|
||||
expect(getOrCreatePaypleIdempotencyKey(storage, OTHER_USER_ID, 'pro_plus', randomUuid)).toBe(changedUser)
|
||||
clearPaypleIdempotencyKey(storage, OTHER_USER_ID, 'pro_plus', changedUser)
|
||||
expect(values.size).toBe(0)
|
||||
})
|
||||
|
||||
test('completes one server charge for a successful SDK callback', async () => {
|
||||
let request: PaypleAuthRequest | null = null
|
||||
let chargeCount = 0
|
||||
let chargedPayerId = ''
|
||||
const checkout = runPaypleRegistration({
|
||||
...baseOptions(),
|
||||
openSdk: (candidate) => { request = candidate },
|
||||
chargeBillingKey: async (payerId) => {
|
||||
chargeCount += 1
|
||||
chargedPayerId = payerId
|
||||
},
|
||||
timeoutMs: 1000
|
||||
})
|
||||
|
||||
const captured = await waitForRequest(() => request)
|
||||
captured.callbackFunction({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'billing-key-1' })
|
||||
captured.callbackFunction({ PCD_PAY_RST: 'success', PCD_PAYER_ID: 'billing-key-2' })
|
||||
await checkout
|
||||
|
||||
expect(chargeCount).toBe(1)
|
||||
expect(chargedPayerId).toBe('billing-key-1')
|
||||
})
|
||||
|
||||
test('cancellation and malformed success never call the charge endpoint', async () => {
|
||||
for (const result of [
|
||||
{ PCD_PAY_RST: 'error', PCD_PAY_MSG: '사용자 취소' },
|
||||
{ PCD_PAY_RST: 'success' },
|
||||
null
|
||||
]) {
|
||||
let request: PaypleAuthRequest | null = null
|
||||
let charged = false
|
||||
const checkout = runPaypleRegistration({
|
||||
...baseOptions(),
|
||||
openSdk: (candidate) => { request = candidate },
|
||||
chargeBillingKey: async () => { charged = true },
|
||||
timeoutMs: 1000
|
||||
})
|
||||
const rejection = checkout.catch((error: unknown) => error)
|
||||
const captured = await waitForRequest(() => request)
|
||||
captured.callbackFunction(result)
|
||||
const error = await rejection
|
||||
|
||||
expect(error).toBeInstanceOf(PaypleClientError)
|
||||
expect(['cancelled', 'missing_billing_key']).toContain((error as PaypleClientError).code)
|
||||
expect(charged).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
test('SDK throw and timeout remain failures without charging', async () => {
|
||||
let charged = false
|
||||
await expect(runPaypleRegistration({
|
||||
...baseOptions(),
|
||||
openSdk: () => { throw new Error('sdk exploded') },
|
||||
chargeBillingKey: async () => { charged = true },
|
||||
timeoutMs: 1000
|
||||
})).rejects.toMatchObject({ code: 'sdk_failed' })
|
||||
expect(charged).toBe(false)
|
||||
|
||||
await expect(runPaypleRegistration({
|
||||
...baseOptions(),
|
||||
openSdk: () => undefined,
|
||||
chargeBillingKey: async () => { charged = true },
|
||||
timeoutMs: 10
|
||||
})).rejects.toMatchObject({ code: 'sdk_timeout' })
|
||||
expect(charged).toBe(false)
|
||||
})
|
||||
|
||||
test('requires an exact successful server confirmation', () => {
|
||||
expect(() => assertSuccessfulCheckoutResponse({
|
||||
success: true,
|
||||
tier: 'pro',
|
||||
order_id: 'D3RO-20260821-order',
|
||||
amount: 9900
|
||||
}, 'pro', 9900)).not.toThrow()
|
||||
|
||||
for (const response of [
|
||||
null,
|
||||
{},
|
||||
{ success: false, tier: 'pro', order_id: 'order', amount: 9900 },
|
||||
{ success: true, tier: 'pro_plus', order_id: 'order', amount: 9900 },
|
||||
{ success: true, tier: 'pro', order_id: '', amount: 9900 },
|
||||
{ success: true, tier: 'pro', order_id: 'order', amount: 1 }
|
||||
]) {
|
||||
expect(() => assertSuccessfulCheckoutResponse(response, 'pro', 9900)).toThrow(PaypleClientError)
|
||||
}
|
||||
|
||||
expect(() => assertSuccessfulCheckoutResponse({
|
||||
success: true,
|
||||
tier: 'pro',
|
||||
order_id: 'order',
|
||||
amount: 9900
|
||||
}, 'pro', 29900)).toThrow(PaypleClientError)
|
||||
})
|
||||
})
|
||||
84
apps/web/e2e/web-stt-client.spec.ts
Normal file
84
apps/web/e2e/web-stt-client.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { transcribeWebAudio, WebSttError } from '../src/lib/web-stt-client'
|
||||
|
||||
const audio = new Blob([new Uint8Array([0x1a, 0x45, 0xdf, 0xa3])], { type: 'audio/webm;codecs=opus' })
|
||||
|
||||
test.describe('web STT fail-closed client', () => {
|
||||
test('sends one authenticated request and accepts only a strict real transcript', async () => {
|
||||
let calls = 0
|
||||
const result = await transcribeWebAudio({
|
||||
audio,
|
||||
accessToken: 'access-token',
|
||||
supabaseUrl: 'https://project.supabase.co',
|
||||
fetchImpl: async (input, init) => {
|
||||
calls += 1
|
||||
expect(String(input)).toBe('https://project.supabase.co/functions/v1/stt-proxy')
|
||||
expect(init?.headers).toEqual({ Authorization: 'Bearer access-token' })
|
||||
expect(init?.body).toBeInstanceOf(FormData)
|
||||
return new Response(JSON.stringify({
|
||||
transcript: '실제 전사 결과',
|
||||
confidence: 0.97,
|
||||
language_code: 'ko',
|
||||
duration_seconds: 1.25,
|
||||
provider: 'whisper.cpp-local',
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } })
|
||||
},
|
||||
})
|
||||
expect(calls).toBe(1)
|
||||
expect(result.transcript).toBe('실제 전사 결과')
|
||||
})
|
||||
|
||||
test('maps auth, quota, provider and upstream failures without a fallback request', async () => {
|
||||
for (const [status, error, code] of [
|
||||
[401, 'invalid_token', 'auth_required'],
|
||||
[429, 'quota_exceeded', 'quota_exceeded'],
|
||||
[503, 'stt_provider_unavailable', 'provider_unavailable'],
|
||||
[502, 'stt_upstream_failed', 'upstream_failed'],
|
||||
] as const) {
|
||||
let calls = 0
|
||||
const rejection = transcribeWebAudio({
|
||||
audio,
|
||||
accessToken: 'token',
|
||||
supabaseUrl: 'https://project.supabase.co',
|
||||
fetchImpl: async () => {
|
||||
calls += 1
|
||||
return new Response(JSON.stringify({ error }), { status })
|
||||
},
|
||||
})
|
||||
await expect(rejection).rejects.toMatchObject({ code })
|
||||
expect(calls).toBe(1)
|
||||
}
|
||||
})
|
||||
|
||||
test('rejects empty, oversized, wrong MIME, malformed success and insecure endpoints', async () => {
|
||||
const noFetch = async (): Promise<Response> => { throw new Error('must not fetch') }
|
||||
const invalidCalls = [
|
||||
transcribeWebAudio({ audio: new Blob([], { type: 'audio/webm' }), accessToken: 't', supabaseUrl: 'https://p.supabase.co', fetchImpl: noFetch }),
|
||||
transcribeWebAudio({ audio: new Blob([new Uint8Array(25 * 1024 * 1024 + 1)], { type: 'audio/webm' }), accessToken: 't', supabaseUrl: 'https://p.supabase.co', fetchImpl: noFetch }),
|
||||
transcribeWebAudio({ audio: new Blob(['x'], { type: 'audio/mpeg' }), accessToken: 't', supabaseUrl: 'https://p.supabase.co', fetchImpl: noFetch }),
|
||||
transcribeWebAudio({ audio, accessToken: 't', supabaseUrl: 'http://remote.example', fetchImpl: noFetch }),
|
||||
transcribeWebAudio({
|
||||
audio,
|
||||
accessToken: 't',
|
||||
supabaseUrl: 'https://p.supabase.co',
|
||||
fetchImpl: async () => new Response(JSON.stringify({ transcript: 'placeholder' }), { status: 200 }),
|
||||
}),
|
||||
]
|
||||
for (const call of invalidCalls) await expect(call).rejects.toBeInstanceOf(WebSttError)
|
||||
})
|
||||
|
||||
test('aborts the only provider request as cancelled', async () => {
|
||||
const controller = new AbortController()
|
||||
const promise = transcribeWebAudio({
|
||||
audio,
|
||||
accessToken: 'token',
|
||||
supabaseUrl: 'https://project.supabase.co',
|
||||
signal: controller.signal,
|
||||
fetchImpl: async (_input, init) => new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener('abort', () => reject(new DOMException('aborted', 'AbortError')), { once: true })
|
||||
}),
|
||||
})
|
||||
controller.abort()
|
||||
await expect(promise).rejects.toMatchObject({ code: 'cancelled' })
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue