Ctrl K
ComunidadGuías…

tests/app-onboarding.unit.test.ts

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

Archivo original ↗Preguntar a KiBot
import type { AppOnboardingPatch } from '../supabase/functions/_backend/utils/appOnboarding.ts'
import { describe, expect, it } from 'vitest'
import { INIT_ONBOARDING_STEP_IDS } from '../cli/src/init/onboarding-steps'
import messages from '../messages/en.json'
import {
  APP_ONBOARDING_ADD_CODE_INFERRED_ANNOTATION,
  APP_ONBOARDING_OTA_V1_STEP_IDS,
  APP_ONBOARDING_V1_STEP_IDS,
  APP_ONBOARDING_V2_STEP_IDS,
  APP_ONBOARDING_V3_STEP_IDS,
  appendAppOnboardingStepHistory,
  applyAppOnboardingPatch,
  applyAppOnboardingStepAnnotations,

  defaultAppOnboarding,
  filterAppOnboardingReportedPatch,
  getAppOnboardingStepHistoryChanges,
  getAppOnboardingStepIds,
  hasSupportedOtaTodoList,
  mergeAppOnboarding,
  parseAppOnboarding,
  parseAppOnboardingPatch,
  pickAppOnboardingSource,
} from '../supabase/functions/_backend/utils/appOnboarding.ts'

describe('app onboarding merge', () => {
  it.concurrent.each([3, 4])('resets a trusted v%s step without retaining completion metadata or recording history', (version) => {
    const step = { status: 'done', at: 'old', update_history: [{ status: 'done', at: 'old' }], annotations: ['old'], type: 'observed' }
    const current = { setup: { todo_list_version: version, ...(version === 4 ? { ota_todo_list_version: '1' } : {}), steps: version === 4 ? { ota: { add_channel: step } } : { add_channel: step } } }
    const patch = { steps: { add_channel: { status: 'pending' as const } } }
    const merged = applyAppOnboardingPatch(current, patch, () => 'new')
    const next = appendAppOnboardingStepHistory(current, merged, patch, () => 'new') as any
    expect(version === 4 ? next.setup.steps.ota.add_channel : next.setup.steps.add_channel).toEqual(version === 4 ? { status: 'pending' } : undefined)
    expect(getAppOnboardingStepHistoryChanges(current, next, patch)).toEqual([])
    expect(parseAppOnboardingPatch(patch)).toBeNull()
    expect(parseAppOnboardingPatch({ steps: { ota: patch.steps } })).toBeNull()
    expect(current.setup.steps).toEqual(version === 4 ? { ota: { add_channel: step } } : { add_channel: step })
  })

  it.concurrent('keeps CLI step ids in sync with the CLI onboarding list', () => {
    // CLI titles live in cli/src/init/onboarding-steps.ts; backend ids must stay identical.
    expect([...APP_ONBOARDING_V1_STEP_IDS]).toEqual([...INIT_ONBOARDING_STEP_IDS])
    expect(APP_ONBOARDING_V2_STEP_IDS).toEqual(['login_cli_mcp', ...INIT_ONBOARDING_STEP_IDS.slice(1)])
    expect(getAppOnboardingStepIds(1)).toBe(APP_ONBOARDING_V1_STEP_IDS)
    expect(getAppOnboardingStepIds(2)).toBe(APP_ONBOARDING_V2_STEP_IDS)
    expect(getAppOnboardingStepIds(99)).toBe(APP_ONBOARDING_V2_STEP_IDS)
  })

  it.concurrent('translates every OTA step title, which the checklist looks up by dynamic key', () => {
    for (const id of APP_ONBOARDING_OTA_V1_STEP_IDS)
      expect((messages as Record<string, string>)[`setup-checklist-step-${id}`]).toBeTruthy()
  })

  it.concurrent('defaults missing onboarding to manual in progress', () => {
    expect(parseAppOnboarding(null)).toEqual(defaultAppOnboarding())
    expect(parseAppOnboarding('nope')).toEqual(defaultAppOnboarding())
    expect(defaultAppOnboarding().todo_list_version).toBe(2)
  })

  it.concurrent('reads nested setup without dropping the feature ledger', () => {
    const ledger = {
      refreshed_at: '2026-08-14T00:00:00.000Z',
      features: { ota: { stage: 'local_only' } },
      setup: {
        todo_list_version: 2,
        source: 'cli',
        outcome: 'in_progress',
        steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } },
      },
    }

    expect(parseAppOnboarding(ledger)).toMatchObject({
      source: 'cli',
      outcome: 'in_progress',
      todo_list_version: 2,
      steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } },
    })

    const next = applyAppOnboardingPatch(ledger, {
      steps: { add_channel: { status: 'done' } },
    }, () => '2026-08-14T10:02:00.000Z')

    expect(next.features).toEqual({ ota: { stage: 'local_only' } })
    expect(next.refreshed_at).toBe('2026-08-14T00:00:00.000Z')
    expect(parseAppOnboarding(next).todo_list_version).toBe(2)
    expect(parseAppOnboarding(next).steps.add_channel?.status).toBe('done')
    expect(next.source).toBeUndefined()
  })

  it.concurrent('never downgrades a stronger onboarding source', () => {
    expect(pickAppOnboardingSource('cli', 'ai')).toBe('cli')
    expect(pickAppOnboardingSource('manual', 'ai')).toBe('ai')
    expect(pickAppOnboardingSource('cli', 'mcp')).toBe('mcp')
  })

  it.concurrent('keeps a done step when a later skip arrives', () => {
    const first = mergeAppOnboarding({ todo_list_version: 1 }, {
      source: 'cli',
      steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } },
    }, () => '2026-08-14T10:00:00.000Z')

    const skipped = mergeAppOnboarding(first, {
      steps: { add_app: { status: 'skipped' } },
    }, () => '2026-08-14T10:01:00.000Z')

    expect(skipped.steps.add_app).toEqual({
      status: 'done',
      at: '2026-08-14T10:00:00.000Z',
    })
    expect(skipped.source).toBe('cli')
    expect(skipped.outcome).toBe('in_progress')
  })

  it.concurrent('marks completed only when every CLI step is done', () => {
    const steps = Object.fromEntries(APP_ONBOARDING_V2_STEP_IDS.map(id => [id, { status: 'done' as const }]))
    const completed = mergeAppOnboarding({ source: 'cli' }, { steps }, () => '2026-08-14T12:00:00.000Z')
    expect(completed.outcome).toBe('completed')

    const skipped = mergeAppOnboarding({ source: 'cli' }, {
      steps: {
        ...steps,
        run_device: { status: 'skipped' },
      },
    }, () => '2026-08-14T12:00:00.000Z')
    expect(skipped.outcome).toBe('skipped')
  })

  it.concurrent('accepts only the steps defined by each todo list version', () => {
    const v1 = mergeAppOnboarding({ todo_list_version: 1 }, {
      steps: {
        add_app: { status: 'done' },
        login_cli_mcp: { status: 'done' },
      },
    })
    expect(v1.steps.add_app?.status).toBe('done')
    expect(v1.steps.login_cli_mcp).toBeUndefined()

    const v2 = mergeAppOnboarding({ todo_list_version: 2 }, {
      steps: {
        add_app: { status: 'done' },
        login_cli_mcp: { status: 'done' },
      },
    })
    expect(v2.steps.add_app).toBeUndefined()
    expect(v2.steps.login_cli_mcp?.status).toBe('done')
  })

  it.concurrent('keeps the original step timestamp when a later report omits at', () => {
    const first = mergeAppOnboarding({ todo_list_version: 1 }, {
      source: 'cli',
      steps: { add_app: { status: 'done', at: '2026-08-14T10:00:00.000Z' } },
    }, () => '2026-08-14T10:00:00.000Z')

    const again = mergeAppOnboarding(first, {
      steps: { add_app: { status: 'done' } },
    }, () => '2026-08-14T11:00:00.000Z')

    expect(again.steps.add_app).toEqual({
      status: 'done',
      at: '2026-08-14T10:00:00.000Z',
    })
  })

  it.concurrent('keeps switched_to_manual until the CLI finishes', () => {
    const switched = mergeAppOnboarding({ source: 'cli' }, {
      outcome: 'switched_to_manual',
      steps: { add_app: { status: 'done' } },
    }, () => '2026-08-14T12:00:00.000Z')
    expect(switched.outcome).toBe('switched_to_manual')
  })

  it.concurrent('ignores empty or unknown onboarding patches', () => {
    expect(parseAppOnboardingPatch({})).toBeNull()
    expect(parseAppOnboardingPatch({ source: 'web' })).toBeNull()
    expect(parseAppOnboardingPatch({ steps: { not_a_step: { status: 'done' } } })).toBeNull()
    expect(parseAppOnboardingPatch({ source: 'ai' })).toEqual({ source: 'ai' })
    expect(parseAppOnboardingPatch({ todo_list_version: 99 })).toBeNull()
  })

  it.concurrent('ignores client history and caps server history with an overflow marker', () => {
    expect(parseAppOnboardingPatch({
      steps: { build_project: { status: 'done', update_history: [{ forged: true }] } },
    })).toEqual({ steps: { build_project: { status: 'done' } } })

    let current: Record<string, unknown> = {
      setup: {
        source: 'cli',
        outcome: 'in_progress',
        steps: { build_project: { status: 'done', at: 'step-0' } },
      },
    }
    for (let update = 1; update <= 11; update++) {
      const patch = { steps: { build_project: { status: 'done' as const, at: `step-${update}` } } }
      const merged = applyAppOnboardingPatch(current, patch, () => `merge-${update}`)
      current = appendAppOnboardingStepHistory(current, merged, patch, () => `server-${update}`)
    }

    const setup = current.setup as { steps: { build_project: { update_history: Array<Record<string, unknown>> } } }
    const history = setup.steps.build_project.update_history
    expect(history).toHaveLength(10)
    expect(history[0]).toEqual({ status: 'done', at: 'server-1' })
    expect(history[9]).toEqual({ type: 'update_history_full', at: 'server-11' })

    const duplicatePatch = { steps: { build_project: { status: 'done' as const, at: 'step-11' } } }
    const duplicateMerge = applyAppOnboardingPatch(current, duplicatePatch, () => 'merge-duplicate')
    const duplicate = appendAppOnboardingStepHistory(current, duplicateMerge, duplicatePatch, () => 'server-duplicate')
    const duplicateSetup = duplicate.setup as { steps: { build_project: { update_history: unknown[] } } }
    expect(duplicateSetup.steps.build_project.update_history).toEqual(history)
    expect(getAppOnboardingStepHistoryChanges(current, duplicate, duplicatePatch)).toEqual([])

    const changedPatch = { steps: { login_cli_mcp: { status: 'done' as const, at: 'step-new' } } }
    const changedMerge = applyAppOnboardingPatch(current, changedPatch, () => 'merge-new')
    const changed = appendAppOnboardingStepHistory(current, changedMerge, changedPatch, () => 'server-new')
    expect(getAppOnboardingStepHistoryChanges(current, changed, changedPatch)).toEqual([{
      stepId: 'login_cli_mcp',
      status: 'done',
      at: 'server-new',
      historyLength: 1,
      historyFull: false,
    }])
  })
})

describe('seven-goal checklist v3', () => {
  const current = { setup: { todo_list_version: 3, steps: {} } }
  it.concurrent('uses seven required milestones without changing v1/v2 or the default', () => {
    expect(getAppOnboardingStepIds(3)).toEqual(['login_cli_mcp', 'add_channel', 'add_updater', 'add_code', 'run_device', 'upload_bundle', 'test_update'])
    expect(defaultAppOnboarding().todo_list_version).toBe(2)
  })
  it.concurrent('ignores legacy CLI completion until all seven goals are present', () => {
    const state = mergeAppOnboarding(current, { outcome: 'completed', steps: { completion: { status: 'done' }, add_encryption: { status: 'skipped' } } })
    expect(state.outcome).toBe('in_progress')
    expect(state.steps).toEqual({})
    expect(mergeAppOnboarding(current, { steps: Object.fromEntries(APP_ONBOARDING_V3_STEP_IDS.map(id => [id, { status: 'done' }])) }).outcome).toBe('completed')
    expect(mergeAppOnboarding(current, { outcome: 'skipped' }).outcome).toBe('skipped')
  })
  it.concurrent('keeps app-ready reports while requiring backend evidence for three milestones', () => {
    const patch = { source: 'cli' as const, steps: { add_code: { status: 'done' as const }, run_device: { status: 'done' as const }, upload_bundle: { status: 'done' as const }, test_update: { status: 'done' as const } } }
    expect(filterAppOnboardingReportedPatch(current, patch).steps).toEqual({ add_code: { status: 'done' } })
    expect(filterAppOnboardingReportedPatch({ setup: { todo_list_version: 2 } }, patch)).toBe(patch)
  })
  it.concurrent('preserves other milestone histories during an automatic update', () => {
    const saved = { setup: { todo_list_version: 3, steps: { add_code: { status: 'done', update_history: [{ status: 'done', at: '2026-09-16' }] } } } }
    const next = applyAppOnboardingPatch(saved, { steps: { run_device: { status: 'done' } } })
    expect((next.setup as any).steps.add_code.update_history).toEqual(saved.setup.steps.add_code.update_history)
  })
})

describe('version 4 OTA checklist', () => {
  const pending = Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'pending' }]))
  const current = { setup: { todo_list_version: 4, ota_todo_list_version: '1', paths: ['ota'], selected_path: 'ota', steps: { ota: pending } }, features: { ota: { stage: 'local_only' } } }

  it.concurrent('reads the same seven goals under the OTA path and keeps all pending steps visible', () => {
    expect(getAppOnboardingStepIds(4, '1')).toEqual(APP_ONBOARDING_OTA_V1_STEP_IDS)
    expect(parseAppOnboarding(current).steps).toEqual(pending)
    expect(parseAppOnboarding({ setup: { todo_list_version: 4, ota_todo_list_version: '1', steps: { ota: {} } } }).steps).toEqual(pending)
    expect(parseAppOnboarding({ setup: { todo_list_version: 3, steps: {} } }).steps).toEqual({})
  })

  it.concurrent('requires the string OTA version before reading or updating v4 steps', () => {
    expect(getAppOnboardingStepIds(4)).toEqual([])
    expect(getAppOnboardingStepIds(4, '2')).toEqual([])
    expect(hasSupportedOtaTodoList(parseAppOnboarding(current))).toBe(true)
    for (const otaVersion of [undefined, 1, '2']) {
      const unsupported = { setup: { todo_list_version: 4, ota_todo_list_version: otaVersion, steps: { ota: pending } } }
      expect(hasSupportedOtaTodoList(parseAppOnboarding(unsupported))).toBe(false)
      expect(parseAppOnboarding(unsupported).steps).toEqual({})
      const merged = applyAppOnboardingPatch(unsupported, { steps: { add_code: { status: 'done' } } }) as any
      expect(merged.setup.steps.ota.add_code).toEqual({ status: 'pending' })
      expect(merged.setup.paths).toBeUndefined()
      expect(merged.setup.outcome).toBe('in_progress')
    }
    const builderOnly = { setup: { todo_list_version: 4, paths: ['builder'], selected_path: 'builder', steps: { builder: { configure_signing: { status: 'pending' } } } } }
    const merged = applyAppOnboardingPatch(builderOnly, { steps: { add_code: { status: 'done' } } }) as any
    expect(merged.setup.paths).toEqual(['builder'])
    expect(merged.setup.steps.ota).toBeUndefined()
    expect(merged.setup.steps.builder).toEqual(builderOnly.setup.steps.builder)
  })

  it.concurrent('writes nested progress while preserving the path and feature ledger', () => {
    const patch = parseAppOnboardingPatch({ steps: { ota: { add_code: { status: 'done' }, add_channel: { status: 'pending' } } } })!
    expect(patch.steps).toEqual({ add_code: { status: 'done' } })
    const merged = applyAppOnboardingPatch(current, patch, () => '2026-09-19T00:00:00.000Z')
    const setup = merged.setup as any
    expect(setup.ota_todo_list_version).toBe('1')
    expect(setup.paths).toEqual(['ota'])
    expect(setup.selected_path).toBe('ota')
    expect(setup.steps.ota.add_code).toEqual({ status: 'done', at: '2026-09-19T00:00:00.000Z' })
    expect(setup.steps.ota.add_channel).toEqual({ status: 'pending' })
    expect(setup.steps.add_code).toBeUndefined()
    expect(merged.features).toEqual(current.features)
    expect(parseAppOnboarding(merged).outcome).toBe('in_progress')
  })

  it.concurrent('records nested history and requires all seven reported goals', () => {
    const patch = { steps: { add_code: { status: 'done' as const } } }
    const merged = applyAppOnboardingPatch(current, patch, () => '2026-09-19T00:00:00.000Z')
    const withHistory = appendAppOnboardingStepHistory(current, merged, patch, () => '2026-09-19T00:00:01.000Z')
    expect((withHistory.setup as any).steps.ota.add_code.update_history).toEqual([{ status: 'done', at: '2026-09-19T00:00:01.000Z' }])
    expect(getAppOnboardingStepHistoryChanges(current, withHistory, patch)).toHaveLength(1)
    expect(mergeAppOnboarding(current, { outcome: 'completed' }).outcome).toBe('in_progress')
    expect(mergeAppOnboarding(current, { steps: Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'done' }])) }).outcome).toBe('completed')
    expect(filterAppOnboardingReportedPatch(current, { steps: { add_code: { status: 'done' }, run_device: { status: 'done' } } }).steps).toEqual({ add_code: { status: 'done' } })
  })

  it.concurrent('stores inferred add-code provenance and clears it after direct CLI evidence', () => {
    const inferredPatch: AppOnboardingPatch = {
      steps: {
        add_code: {
          status: 'done' as const,
          annotation: APP_ONBOARDING_ADD_CODE_INFERRED_ANNOTATION,
          annotationType: 'note' as const,
        },
      },
    }
    const inferred = applyAppOnboardingPatch(current, inferredPatch, () => '2026-09-30T10:00:00.000Z') as any
    const withHistory = appendAppOnboardingStepHistory(current, inferred, inferredPatch, () => '2026-09-30T10:00:01.000Z')
    expect(inferred.setup.steps.ota.add_code).toMatchObject({
      status: 'done',
      annotation: APP_ONBOARDING_ADD_CODE_INFERRED_ANNOTATION,
      annotation_type: 'note',
    })
    expect(getAppOnboardingStepHistoryChanges(current, withHistory, inferredPatch)).toMatchObject([{
      stepId: 'add_code',
      completionSource: 'inferred',
      inferredFromStepId: 'test_update',
    }])

    const directPatch = parseAppOnboardingPatch({
      steps: { ota: { add_code: { status: 'done', annotation: 'forged' } } },
    })!
    expect(directPatch).toEqual({ steps: { add_code: { status: 'done' } } })
    const directlyVerified = applyAppOnboardingStepAnnotations(inferred, inferred, directPatch) as any
    expect(directlyVerified.setup.steps.ota.add_code).not.toHaveProperty('annotation')
    expect(directlyVerified.setup.steps.ota.add_code).not.toHaveProperty('annotation_type')
  })
})

describe('builder-only checklist v4', () => {
  it.concurrent('preserves Builder steps without creating an OTA path during legacy progress updates', () => {
    const current = { setup: {
      todo_list_version: 4,
      builder_todo_list_version: '1',
      paths: ['builder'],
      selected_path: 'builder',
      steps: { builder: { ios: { start_setup: { status: 'pending' } }, android: { start_setup: { status: 'pending' } } } },
    } }
    const patch = { source: 'cli' as const, steps: { login_cli_mcp: { status: 'done' as const } } }
    const merged = applyAppOnboardingPatch(current, patch)
    const withHistory = appendAppOnboardingStepHistory(current, merged, patch)
    const setup = withHistory.setup as any

    expect(hasSupportedOtaTodoList(parseAppOnboarding(withHistory))).toBe(false)
    expect(setup.steps).toEqual(current.setup.steps)
    expect(setup.paths).toEqual(['builder'])
    expect(setup.selected_path).toBe('builder')
    expect(setup.outcome).toBe('in_progress')
    expect(getAppOnboardingStepHistoryChanges(current, withHistory, patch)).toEqual([])
  })
})