Ctrl K
ComunidadGuías…

tests/app-onboarding-progress.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import type { Database } from '~/types/database.types'
import { randomUUID } from 'node:crypto'
import { createClient } from '@supabase/supabase-js'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { parseAppOnboardingLedger, shouldShowGettingStartedNav, shouldSkipOnboardingResume } from '../src/utils/appOnboardingProgress.ts'
import {
  BASE_URL,
  executeSQL,
  fetchTestRequest,
  getAuthHeaders,
  getSupabaseClient,
  ORG_ID,
  SUPABASE_ANON_KEY,
  SUPABASE_BASE_URL,
  USER_EMAIL,
  USER_EMAIL_NONMEMBER,
  USER_ID,
  USER_ID_NONMEMBER,
  USER_PASSWORD,
  USER_PASSWORD_NONMEMBER,
} from './test-utils.ts'

const APP_RPC = `ob.rpc.${randomUUID().slice(0, 8)}`
const APP_INSERT = `ob.ins.${randomUUID().slice(0, 8)}`
const APP_TESTFLIGHT = `ob.tf.${randomUUID().slice(0, 8)}`
const APP_STORE = `ob.st.${randomUUID().slice(0, 8)}`
const APP_VERIFY = `ob.vf.${randomUUID().slice(0, 8)}`
const APP_SETUP = `ob.su.${randomUUID().slice(0, 8)}`
const APP_HISTORY = `ob.hi.${randomUUID().slice(0, 8)}`
const DEVICE_TF = randomUUID().toLowerCase()
const DEVICE_STORE = randomUUID().toLowerCase()

const serviceRoleSupabase = getSupabaseClient()

function createAuthClient() {
  return createClient<Database>(SUPABASE_BASE_URL, SUPABASE_ANON_KEY, {
    auth: {
      persistSession: false,
      autoRefreshToken: false,
      detectSessionInUrl: false,
    },
  })
}

async function bindAppReader(appId: string, userId: string) {
  await executeSQL(`
    INSERT INTO public.role_bindings (
      principal_type,
      principal_id,
      role_id,
      scope_type,
      org_id,
      app_id,
      granted_by,
      reason,
      is_direct
    )
    SELECT
      public.rbac_principal_user(),
      $1::uuid,
      roles.id,
      public.rbac_scope_app(),
      $2::uuid,
      apps.id,
      $3::uuid,
      'Getting started reader regression',
      true
    FROM public.roles roles
    INNER JOIN public.apps apps ON apps.app_id = $4
    WHERE roles.name = public.rbac_role_app_reader()
      AND roles.scope_type = public.rbac_scope_app()
    ON CONFLICT DO NOTHING
  `, [userId, ORG_ID, USER_ID, appId])
}

async function unbindAppReader(appId: string, userId: string) {
  await executeSQL(`
    DELETE FROM public.role_bindings
    WHERE principal_type = public.rbac_principal_user()
      AND principal_id = $1::uuid
      AND app_id = (SELECT id FROM public.apps WHERE app_id = $2)
  `, [userId, appId])
}

async function createApp(appId: string, needOnboarding = false) {
  const { error } = await serviceRoleSupabase.from('apps').insert({
    app_id: appId,
    owner_org: ORG_ID,
    name: 'Onboarding progress test app',
    icon_url: 'https://example.com/icon.png',
    need_onboarding: needOnboarding,
  })
  if (error)
    throw error
}

async function insertDevice(appId: string, deviceId: string, installSource: string) {
  const { error } = await serviceRoleSupabase.from('devices').insert({
    app_id: appId,
    device_id: deviceId,
    platform: 'ios',
    plugin_version: '7.0.0',
    updated_at: new Date().toISOString(),
    version_name: '1.0.0',
    install_source: installSource,
    is_prod: true,
    is_emulator: false,
  })
  if (error)
    throw error
}

async function refreshOne(appId: string) {
  const rows = await executeSQL<{ onboarding: unknown }>(
    'SELECT public.refresh_one_app_onboarding_progress($1) AS onboarding',
    [appId],
  )
  return parseAppOnboardingLedger(rows[0]?.onboarding)
}

beforeAll(async () => {
  await createApp(APP_RPC)
  await createApp(APP_TESTFLIGHT)
  await createApp(APP_STORE)
  await createApp(APP_VERIFY, true)
  await createApp(APP_SETUP, true)
  await createApp(APP_HISTORY, true)
  await insertDevice(APP_TESTFLIGHT, DEVICE_TF, 'testflight')
  await insertDevice(APP_STORE, DEVICE_STORE, 'app_store')
})

afterAll(async () => {
  await serviceRoleSupabase.from('devices').delete().eq('app_id', APP_TESTFLIGHT)
  await serviceRoleSupabase.from('devices').delete().eq('app_id', APP_STORE)
  await serviceRoleSupabase.from('app_versions').delete().eq('app_id', APP_VERIFY)
  await serviceRoleSupabase.from('apps').delete().in('app_id', [APP_RPC, APP_INSERT, APP_TESTFLIGHT, APP_STORE, APP_VERIFY, APP_SETUP, APP_HISTORY])
})

describe('app onboarding progress', () => {
  it.concurrent('defaults and preserves the server-owned todo list version', async () => {
    const rows = await executeSQL<{ default_version: number, preserved_version: number }>(`
      SELECT
        (public.merge_app_onboarding_setup('{}'::jsonb, '{}'::jsonb)
          -> 'setup' ->> 'todo_list_version')::integer AS default_version,
        (public.merge_app_onboarding_setup(
          '{"setup":{"todo_list_version":1}}'::jsonb,
          '{"todo_list_version":99,"source":"cli"}'::jsonb
        ) -> 'setup' ->> 'todo_list_version')::integer AS preserved_version
    `)

    expect(rows[0]).toEqual({ default_version: 2, preserved_version: 1 })
  })

  it.concurrent('merges only steps belonging to the stored todo list version', async () => {
    const rows = await executeSQL<Record<string, boolean>>(`
      SELECT
        public.merge_app_onboarding_setup(
          '{"setup":{"todo_list_version":1}}'::jsonb,
          '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb
        ) -> 'setup' -> 'steps' ? 'add_app' AS v1_add_app,
        public.merge_app_onboarding_setup(
          '{"setup":{"todo_list_version":1}}'::jsonb,
          '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb
        ) -> 'setup' -> 'steps' ? 'login_cli_mcp' AS v1_login,
        public.merge_app_onboarding_setup(
          '{"setup":{"todo_list_version":2}}'::jsonb,
          '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb
        ) -> 'setup' -> 'steps' ? 'add_app' AS v2_add_app,
        public.merge_app_onboarding_setup(
          '{"setup":{"todo_list_version":2}}'::jsonb,
          '{"steps":{"add_app":{"status":"done"},"login_cli_mcp":{"status":"done"}}}'::jsonb
        ) -> 'setup' -> 'steps' ? 'login_cli_mcp' AS v2_login
    `)

    expect(rows[0]).toEqual({ v1_add_app: true, v1_login: false, v2_add_app: false, v2_login: true })
  })

  it('must reject unauthenticated mark_onboarding_feature_started', async () => {
    const anon = createAuthClient()
    const { error } = await anon.rpc('mark_onboarding_feature_started', {
      p_app_id: APP_RPC,
      p_feature_key: 'cli_install',
    })
    expect(error).toBeTruthy()
  })

  it('sets started_at and persists it', async () => {
    const authClient = createAuthClient()
    const { error: signInError } = await authClient.auth.signInWithPassword({
      email: USER_EMAIL,
      password: USER_PASSWORD,
    })
    if (signInError)
      throw signInError

    const { data, error } = await authClient.rpc('mark_onboarding_feature_started', {
      p_app_id: APP_RPC,
      p_feature_key: 'cli_install',
    })
    expect(error).toBeNull()
    const ledger = parseAppOnboardingLedger(data)
    expect(ledger.features?.cli_install?.started_at).toBeTruthy()
    expect(ledger.features?.cli_install?.succeeded_at).toBeFalsy()
    expect(ledger.features?.cli_install?.stage).toBeFalsy()

    const { data: persisted, error: readError } = await serviceRoleSupabase
      .from('apps')
      .select('onboarding')
      .eq('app_id', APP_RPC)
      .single()
    expect(readError).toBeNull()
    const persistedLedger = parseAppOnboardingLedger(persisted?.onboarding)
    expect(persistedLedger.features?.cli_install?.started_at).toBeTruthy()
    expect(persistedLedger.features?.cli_install?.succeeded_at).toBeFalsy()
  })

  it('rejects direct authenticated writes to apps.onboarding', async () => {
    const authClient = createAuthClient()
    const { error: signInError } = await authClient.auth.signInWithPassword({
      email: USER_EMAIL,
      password: USER_PASSWORD,
    })
    if (signInError)
      throw signInError

    const { error: _updateError } = await authClient
      .from('apps')
      .update({
        onboarding: {
          features: {
            ota: {
              succeeded_at: '2026-01-01T00:00:00.000Z',
              stage: 'store_live',
            },
          },
        },
      })
      .eq('app_id', APP_RPC)

    const { data, error: readError } = await serviceRoleSupabase
      .from('apps')
      .select('onboarding')
      .eq('app_id', APP_RPC)
      .single()
    expect(readError).toBeNull()
    const ledger = parseAppOnboardingLedger(data?.onboarding)
    expect(ledger.features?.ota?.succeeded_at).toBeFalsy()
    expect(ledger.features?.ota?.stage).not.toBe('store_live')
    expect(ledger.features?.cli_install?.started_at).toBeTruthy()
  })

  it('clears forged onboarding on authenticated insert', async () => {
    const authClient = createAuthClient()
    const { error: signInError } = await authClient.auth.signInWithPassword({
      email: USER_EMAIL,
      password: USER_PASSWORD,
    })
    if (signInError)
      throw signInError

    const { error: insertError } = await authClient.from('apps').insert({
      app_id: APP_INSERT,
      owner_org: ORG_ID,
      user_id: USER_ID,
      name: 'Onboarding insert protection test app',
      icon_url: 'https://example.com/icon.png',
      onboarding: {
        features: {
          ota: {
            succeeded_at: '2026-01-01T00:00:00.000Z',
            stage: 'store_live',
          },
        },
      },
    })
    expect(insertError, JSON.stringify(insertError)).toBeNull()

    const { data, error: readError } = await serviceRoleSupabase
      .from('apps')
      .select('onboarding')
      .eq('app_id', APP_INSERT)
      .single()
    expect(readError).toBeNull()
    expect(data).toBeTruthy()
    const ledger = parseAppOnboardingLedger(data?.onboarding)
    expect(ledger.features?.ota?.succeeded_at).toBeFalsy()
    expect(ledger.features?.ota?.stage).not.toBe('store_live')
  })

  it('keeps TestFlight-only apps off store_live', async () => {
    const testflight = await refreshOne(APP_TESTFLIGHT)
    const store = await refreshOne(APP_STORE)

    expect(testflight.features?.ota?.stage).toBe('testflight')
    expect(testflight.features?.ota?.stage).not.toBe('store_live')
    expect(store.features?.ota?.stage).toBe('store_live')

    const merged = await executeSQL<{ stage: string | null }>(
      `SELECT public.merge_app_onboarding_feature(
         '{"stage":"store_live"}'::jsonb, NULL, NULL, NULL, 'no_device'
       )->>'stage' AS stage`,
    )
    expect(merged[0]?.stage).toBe('store_live')
  })

  it('must reject unauthenticated dismiss_getting_started', async () => {
    const anon = createAuthClient()
    const { error } = await anon.rpc('dismiss_getting_started', {
      p_app_id: APP_RPC,
    })
    expect(error).toBeTruthy()
  })

  it('persists getting started dismiss on the app and survives refresh', async () => {
    const authClient = createAuthClient()
    const { error: signInError } = await authClient.auth.signInWithPassword({
      email: USER_EMAIL,
      password: USER_PASSWORD,
    })
    if (signInError)
      throw signInError

    const { error: markError } = await authClient.rpc('mark_onboarding_feature_started', {
      p_app_id: APP_RPC,
      p_feature_key: 'cli_install',
    })
    expect(markError).toBeNull()

    const { data, error } = await authClient.rpc('dismiss_getting_started', {
      p_app_id: APP_RPC,
    })
    expect(error).toBeNull()
    const ledger = parseAppOnboardingLedger(data)
    expect(ledger.getting_started_dismissed_at).toBeTruthy()
    expect(shouldShowGettingStartedNav(ledger)).toBe(false)
    expect(ledger.features?.cli_install?.started_at).toBeTruthy()

    const firstDismissedAt = ledger.getting_started_dismissed_at
    const { data: again, error: againError } = await authClient.rpc('dismiss_getting_started', {
      p_app_id: APP_RPC,
    })
    expect(againError).toBeNull()
    expect(parseAppOnboardingLedger(again).getting_started_dismissed_at).toBe(firstDismissedAt)

    const refreshed = await refreshOne(APP_RPC)
    expect(refreshed.getting_started_dismissed_at).toBe(firstDismissedAt)
    expect(refreshed.features?.cli_install?.started_at).toBeTruthy()
  })

  it('must reject unauthenticated verify_getting_started', async () => {
    const anon = createAuthClient()
    const { error } = await anon.rpc('verify_getting_started', {
      p_app_id: APP_VERIFY,
    })
    expect(error).toBeTruthy()
  })

  it('verifies live bundles onto the getting started ledger and completes pending onboarding', async () => {
    const { error: versionError } = await serviceRoleSupabase.from('app_versions').insert({
      app_id: APP_VERIFY,
      name: '1.0.1',
      owner_org: ORG_ID,
      user_id: USER_ID,
      storage_provider: 'r2',
      r2_path: `orgs/${ORG_ID}/apps/${APP_VERIFY}/1.0.1.zip`,
    })
    if (versionError)
      throw versionError

    const authClient = createAuthClient()
    const { error: signInError } = await authClient.auth.signInWithPassword({
      email: USER_EMAIL,
      password: USER_PASSWORD,
    })
    if (signInError)
      throw signInError

    const { data, error } = await authClient.rpc('verify_getting_started', {
      p_app_id: APP_VERIFY,
    })
    expect(error).toBeNull()
    const ledger = parseAppOnboardingLedger(data)
    expect(ledger.refreshed_at).toBeTruthy()
    expect(ledger.features?.ota?.started_at).toBeTruthy()
    // A stored bundle is first-bundle time, not an install. Skip-resume still
    // needs succeeded_at, dismiss, or CLI/AI setup. Completing need_onboarding
    // is what stops the login splash after Verify.
    expect(ledger.features?.ota?.succeeded_at).toBeFalsy()
    expect(shouldSkipOnboardingResume(data)).toBe(false)

    const { data: app, error: readError } = await serviceRoleSupabase
      .from('apps')
      .select('need_onboarding')
      .eq('app_id', APP_VERIFY)
      .single()
    expect(readError).toBeNull()
    expect(app?.need_onboarding).toBe(false)
  })

  it('completes pending onboarding when CLI/AI setup reports completed', async () => {
    // Legacy v2 checklists complete on the reported outcome alone; new apps
    // start on OTA v4, so pin this app to the legacy list first.
    const { error: seedError } = await serviceRoleSupabase
      .from('apps')
      .update({ onboarding: { setup: { todo_list_version: 2, source: 'manual', outcome: 'in_progress', steps: {} } } })
      .eq('app_id', APP_SETUP)
    expect(seedError).toBeNull()
    const response = await fetchTestRequest(`${BASE_URL}/app/${APP_SETUP}`, {
      method: 'PUT',
      headers: await getAuthHeaders(),
      body: JSON.stringify({
        onboarding: { outcome: 'completed' },
      }),
    })
    const updated = await response.json() as { onboarding?: unknown }
    expect(response.status, JSON.stringify(updated)).toBe(200)
    expect(shouldSkipOnboardingResume(updated.onboarding)).toBe(true)

    const { data: app, error: readError } = await serviceRoleSupabase
      .from('apps')
      .select('need_onboarding')
      .eq('app_id', APP_SETUP)
      .single()
    expect(readError).toBeNull()
    expect(app?.need_onboarding).toBe(false)
  })

  it('atomically records concurrent step history and rejects forged history', async () => {
    const { error: seedError } = await serviceRoleSupabase
      .from('apps')
      .update({ onboarding: { setup: { todo_list_version: Number.MAX_SAFE_INTEGER } } })
      .eq('app_id', APP_HISTORY)
    expect(seedError).toBeNull()

    const headers = await getAuthHeaders()
    const responses = await Promise.all([
      fetchTestRequest(`${BASE_URL}/app/${APP_HISTORY}`, {
        method: 'PUT',
        headers,
        body: JSON.stringify({
          onboarding: { steps: { login_cli_mcp: { status: 'done', update_history: [{ forged: true }] } } },
        }),
      }),
      fetchTestRequest(`${BASE_URL}/app/${APP_HISTORY}`, {
        method: 'PUT',
        headers,
        body: JSON.stringify({
          onboarding: { steps: { add_channel: { status: 'done' } } },
        }),
      }),
    ])
    expect(responses.map(response => response.status)).toEqual([200, 200])

    const { data, error } = await serviceRoleSupabase
      .from('apps')
      .select('onboarding')
      .eq('app_id', APP_HISTORY)
      .single()
    expect(error).toBeNull()
    const onboarding = data?.onboarding as {
      setup?: {
        todo_list_version?: number
        steps?: Record<string, { status?: string, update_history?: Array<Record<string, unknown>> }>
      }
    }
    expect(onboarding.setup?.todo_list_version).toBe(Number.MAX_SAFE_INTEGER)
    for (const stepId of ['login_cli_mcp', 'add_channel']) {
      const step = onboarding.setup?.steps?.[stepId]
      expect(step?.status).toBe('done')
      expect(step?.update_history).toHaveLength(1)
      expect(step?.update_history?.[0]?.status).toBe('done')
      expect(step?.update_history?.[0]?.at).toEqual(expect.any(String))
    }
    expect(JSON.stringify(onboarding)).not.toContain('forged')
  })

  it('completes pending onboarding when getting started is hidden', async () => {
    const appId = `ob.hide.${randomUUID().slice(0, 8)}`
    await createApp(appId, true)
    try {
      const authClient = createAuthClient()
      const { error: signInError } = await authClient.auth.signInWithPassword({
        email: USER_EMAIL,
        password: USER_PASSWORD,
      })
      if (signInError)
        throw signInError

      const { error } = await authClient.rpc('dismiss_getting_started', {
        p_app_id: appId,
      })
      expect(error).toBeNull()

      const { data: app, error: readError } = await serviceRoleSupabase
        .from('apps')
        .select('need_onboarding, onboarding')
        .eq('app_id', appId)
        .single()
      expect(readError).toBeNull()
      expect(app?.need_onboarding).toBe(false)
      expect(parseAppOnboardingLedger(app?.onboarding).getting_started_dismissed_at).toBeTruthy()
    }
    finally {
      await serviceRoleSupabase.from('apps').delete().eq('app_id', appId)
    }
  })

  it('lets an app reader hide getting started without completing pending onboarding', async () => {
    const appId = `ob.read.${randomUUID().slice(0, 8)}`
    await createApp(appId, true)
    await bindAppReader(appId, USER_ID_NONMEMBER)
    try {
      const authClient = createAuthClient()
      const { error: signInError } = await authClient.auth.signInWithPassword({
        email: USER_EMAIL_NONMEMBER,
        password: USER_PASSWORD_NONMEMBER,
      })
      if (signInError)
        throw signInError

      const { data, error } = await authClient.rpc('dismiss_getting_started', {
        p_app_id: appId,
      })
      expect(error).toBeNull()
      expect(parseAppOnboardingLedger(data).getting_started_dismissed_at).toBeTruthy()

      const { data: app, error: readError } = await serviceRoleSupabase
        .from('apps')
        .select('need_onboarding')
        .eq('app_id', appId)
        .single()
      expect(readError).toBeNull()
      expect(app?.need_onboarding).toBe(true)
    }
    finally {
      await unbindAppReader(appId, USER_ID_NONMEMBER)
      await serviceRoleSupabase.from('apps').delete().eq('app_id', appId)
    }
  })
})