362 lines
20 KiB
TypeScript
362 lines
20 KiB
TypeScript
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' })
|
|
})
|
|
})
|