tests/app-onboarding-apikey-runtime.unit.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import AppOnboardingFlow from '../src/components/dashboard/AppOnboardingFlow.vue'
const runtimeMocks = vi.hoisted(() => {
const app = {
app_id: 'com.test.runtime-onboarding',
name: 'Runtime onboarding app',
owner_org: 'org-runtime-onboarding',
existing_app: false,
icon_url: null,
ios_store_url: null,
android_store_url: null,
}
return {
app,
createApp: vi.fn(async () => ({
ok: true as const,
app,
usedAppId: app.app_id,
originalAppId: app.app_id,
wasRetried: false,
})),
createDefaultApiKey: vi.fn(),
findUsablePlainApiKey: vi.fn(async (): Promise<string | null> => 'runtime-api-key'),
main: {
auth: { id: 'user-runtime-onboarding' },
awaitInitialLoad: vi.fn(async () => undefined),
isAdmin: false,
plans: [],
user: { id: 'user-runtime-onboarding', onboarding: {} } as { id: string, onboarding: Record<string, unknown> } | null,
},
organizationStore: {
getOrgByAppId: vi.fn(),
setCurrentOrganization: vi.fn(),
awaitInitialLoad: vi.fn(async () => undefined),
currentOrganization: { gid: 'org-runtime-onboarding', name: 'Runtime organization' },
organizations: [],
updateAppOnboarding: vi.fn(),
upsertOrganizationApp: vi.fn(),
},
query: {} as Record<string, string>,
router: {
push: vi.fn(),
replace: vi.fn(async () => undefined),
},
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
vi.mock('vue-router', () => ({
useRoute: () => ({ query: runtimeMocks.query }),
useRouter: () => runtimeMocks.router,
}))
vi.mock('vue-sonner', () => ({ toast: { error: vi.fn(), info: vi.fn(), success: vi.fn() } }))
vi.mock('~/services/apikeys', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/services/apikeys')>()
return {
...actual,
createDefaultApiKey: runtimeMocks.createDefaultApiKey,
findUsablePlainApiKey: runtimeMocks.findUsablePlainApiKey,
}
})
vi.mock('~/services/onboardingTracking', () => ({ sendOnboardingEvent: vi.fn() }))
vi.mock('~/services/console', () => ({
getLocalConfig: () => ({ apiHost: 'https://api.capgo.app' }),
isLocal: () => false,
useConsole: () => {
const query = {
eq: () => query,
maybeSingle: async () => ({ data: null, error: null }),
select: () => query,
single: async () => ({ data: runtimeMocks.app, error: null }),
}
return {
auth: { getClaims: async () => ({ data: { claims: { sub: runtimeMocks.main.auth.id } } }) },
from: () => query,
rpc: async () => ({ data: null, error: null }),
}
},
}))
vi.mock('~/stores/dashboardApps', () => ({ useDashboardAppsStore: () => ({ upsertApp: vi.fn() }) }))
vi.mock('~/stores/dialogv2', () => ({
useDialogV2Store: () => ({
lastButtonRole: null,
onDialogDismiss: vi.fn(async () => undefined),
openDialog: vi.fn(),
}),
}))
vi.mock('~/stores/main', () => ({ useMainStore: () => runtimeMocks.main }))
vi.mock('~/stores/organization', () => ({ useOrganizationStore: () => runtimeMocks.organizationStore }))
vi.mock('~/utils/onboardingAppCreateHelpers', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/onboardingAppCreateHelpers')>()
return { ...actual, createOnboardingAppWithFallbackIds: runtimeMocks.createApp }
})
vi.mock('~/utils/onboardingProgressPersistence', () => ({
createOnboardingProgressPersistence: () => ({
abort: vi.fn(),
isAborted: () => false,
isBlocked: () => false,
persist: async () => 'persisted',
}),
shouldInitializeOnboardingProgressTracking: () => true,
}))
interface MountedFlow {
app: ReturnType<typeof createApp>
container: HTMLDivElement
}
const mountedFlows: MountedFlow[] = []
async function mountFlow(query: Record<string, string> = {}, props: { setupAppId?: string } = {}) {
runtimeMocks.query = query
const container = document.createElement('div')
document.body.appendChild(container)
const app = createApp(AppOnboardingFlow, { onboarding: false, ...props })
app.config.warnHandler = () => undefined
app.mount(container)
const mounted = { app, container }
mountedFlows.push(mounted)
return mounted
}
function element<T extends Element>(container: Element, selector: string) {
const value = container.querySelector<T>(selector)
if (!value)
throw new Error(`Missing onboarding element: ${selector}`)
return value
}
async function click(container: Element, selector: string) {
element<HTMLButtonElement>(container, selector).click()
await nextTick()
}
async function reachIconStep(container: Element) {
await vi.waitFor(() => expect(container.querySelector('[data-test="app-onboarding-name"]')).not.toBeNull())
const nameInput = element<HTMLInputElement>(container, '[data-test="app-onboarding-name"]')
nameInput.value = 'Runtime onboarding app'
nameInput.dispatchEvent(new Event('input', { bubbles: true }))
await nextTick()
await click(container, '[data-test="app-onboarding-continue"]')
await vi.waitFor(() => expect(container.textContent).toContain('app-onboarding-icon-step-title'))
}
beforeEach(() => {
runtimeMocks.query = {}
runtimeMocks.createApp.mockClear()
runtimeMocks.router.push.mockClear()
runtimeMocks.router.replace.mockClear()
runtimeMocks.createDefaultApiKey.mockReset()
runtimeMocks.createDefaultApiKey.mockResolvedValue({ data: { key: 'runtime-created-api-key' }, error: null })
runtimeMocks.findUsablePlainApiKey.mockReset()
runtimeMocks.findUsablePlainApiKey.mockResolvedValue('runtime-api-key')
runtimeMocks.main.awaitInitialLoad.mockClear()
runtimeMocks.main.user = { id: runtimeMocks.main.auth.id, onboarding: {} }
runtimeMocks.organizationStore.awaitInitialLoad.mockClear()
runtimeMocks.organizationStore.updateAppOnboarding.mockClear()
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn(() => ({
addEventListener: vi.fn(),
addListener: vi.fn(),
matches: false,
removeEventListener: vi.fn(),
removeListener: vi.fn(),
})),
})
})
afterEach(() => {
for (const mounted of mountedFlows.splice(0)) {
mounted.app.unmount()
mounted.container.remove()
}
})
describe('app onboarding API key runtime loading', () => {
it('reuses a key when a fresh existing-org wizard is opened', async () => {
const { container } = await mountFlow()
await vi.waitFor(() => expect(container.querySelector('[data-test="app-onboarding-name"]')).not.toBeNull())
await vi.waitFor(() => expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalledTimes(1))
expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalledWith(
expect.anything(),
runtimeMocks.main.auth.id,
runtimeMocks.organizationStore.currentOrganization.gid,
undefined,
)
expect(runtimeMocks.createDefaultApiKey).not.toHaveBeenCalled()
})
it('uses the authenticated user ID while the public profile is still loading', async () => {
runtimeMocks.main.user = null
await mountFlow()
await vi.waitFor(() => expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalledTimes(1))
expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalledWith(
expect.anything(),
runtimeMocks.main.auth.id,
runtimeMocks.organizationStore.currentOrganization.gid,
undefined,
)
})
it('retries a settled failed load when setup continues on Getting started', async () => {
const loadError = new Error('transient API-key failure')
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined)
runtimeMocks.findUsablePlainApiKey
.mockRejectedValueOnce(loadError)
.mockResolvedValueOnce('runtime-retried-api-key')
try {
const { container } = await mountFlow()
await vi.waitFor(() => expect(consoleError).toHaveBeenCalledWith('Cannot ensure API key', loadError))
await reachIconStep(container)
await click(container, '[data-test="app-onboarding-continue"]')
await vi.waitFor(() => expect(runtimeMocks.createApp).toHaveBeenCalledTimes(1))
await vi.waitFor(() => expect(runtimeMocks.router.replace).toHaveBeenCalledWith({
path: `/app/${runtimeMocks.app.app_id}/getting-started`,
state: {
capgoOnboardingSetupHandoff: expect.objectContaining({
appId: runtimeMocks.app.app_id,
flow: 'existing_org',
previousStep: 'app_icon',
}),
},
}))
expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalledTimes(1)
// Getting started mounts the setup flow for the created app.
await mountFlow({}, { setupAppId: runtimeMocks.app.app_id })
await vi.waitFor(() => expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalledTimes(2))
expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenLastCalledWith(
expect.anything(),
runtimeMocks.main.auth.id,
runtimeMocks.organizationStore.currentOrganization.gid,
runtimeMocks.app.app_id,
)
}
finally {
consoleError.mockRestore()
}
})
it('shares an in-flight key load across concurrent onboarding component instances', async () => {
let finishLookup: ((key: string | null) => void) | undefined
runtimeMocks.findUsablePlainApiKey.mockImplementation(() => new Promise((resolve) => {
finishLookup = resolve
}))
await Promise.all([
mountFlow(),
mountFlow(),
])
await vi.waitFor(() => expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalled())
expect(runtimeMocks.findUsablePlainApiKey).toHaveBeenCalledTimes(1)
finishLookup?.(null)
await vi.waitFor(() => expect(runtimeMocks.createDefaultApiKey).toHaveBeenCalledTimes(1))
})
})