feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

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

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

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

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

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

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