Ctrl K
ComunidadGuías…

tests/app-onboarding-progress.unit.test.ts

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

Archivo original ↗Preguntar a KiBot
import { describe, expect, it } from 'vitest'
import { APP_ONBOARDING_OTA_V1_STEP_IDS, parseAppOnboarding } from '../src/services/appOnboarding.ts'
import { BUILDER_STEP_IDS } from '../src/services/builderOnboardingChecklist.ts'
import {
  buildGettingStartedSteps,
  getAppOnboardingFeature,
  gettingStartedProgress,
  highestAppOnboardingStage,
  isFeatureRetained30d,
  isFeatureUsedSince,
  nextOnboardingAction,
  onboardingNextStepMessageKeys,
  parseAppOnboardingLedger,
  parseAppOnboardingStage,
  rankAppOnboardingStage,
  shouldShowGettingStartedNav,
  shouldShowOnboardingNextStep,
  shouldSkipOnboardingResume,
  usageProvenStepIds,
  withGettingStartedDismissed,
  withoutGettingStartedDismissed,
  withUsageProvenSteps,
} from '../src/utils/appOnboardingProgress.ts'
import { storeReleaseValidatedKey } from '../src/utils/gettingStartedDismiss.ts'

describe('app onboarding progress ledger', () => {
  it.concurrent('parses feature timestamps and ignores unknown stages', () => {
    const ledger = parseAppOnboardingLedger({
      refreshed_at: '2026-08-13T00:00:00.000Z',
      features: {
        ota: {
          started_at: '2026-08-01T00:00:00.000Z',
          stage: 'testflight',
        },
        cli_install: {
          succeeded_at: '2026-08-02T00:00:00.000Z',
          stage: 'not-a-stage',
        },
      },
    })

    expect(ledger.features?.ota?.stage).toBe('testflight')
    expect(ledger.features?.cli_install?.succeeded_at).toBe('2026-08-02T00:00:00.000Z')
    expect(ledger.features?.cli_install?.stage).toBeNull()
    expect(parseAppOnboardingStage('store_live')).toBe('store_live')
    expect(parseAppOnboardingStage('production')).toBeNull()
  })

  it.concurrent('ranks store_live above testflight and empty', () => {
    expect(rankAppOnboardingStage('store_live')).toBeGreaterThan(rankAppOnboardingStage('testflight'))
    expect(highestAppOnboardingStage(['testflight', 'no_device', 'store_live'])).toBe('store_live')
    expect(highestAppOnboardingStage([null, 'testflight'])).toBe('testflight')
    expect(highestAppOnboardingStage([])).toBeNull()
  })

  it.concurrent('does not treat is_prod-only apps as store live', () => {
    const feature = getAppOnboardingFeature({
      features: { ota: { stage: 'native_unknown' } },
    }, 'ota')
    expect(feature.stage).toBe('native_unknown')
    expect(feature.stage).not.toBe('store_live')
  })

  it.concurrent('sets retained_30d from stored timestamp, not from last_used alone', () => {
    expect(isFeatureRetained30d({
      succeeded_at: '2026-07-01T00:00:00.000Z',
      last_used_at: '2026-08-10T00:00:00.000Z',
    })).toBe(false)
    expect(isFeatureRetained30d({
      retained_30d_at: '2026-08-01T00:00:00.000Z',
    })).toBe(true)
  })

  it.concurrent('detects current quarter usage from last_used_at', () => {
    const now = Date.parse('2026-08-13T00:00:00.000Z')
    expect(isFeatureUsedSince({ last_used_at: '2026-07-01T00:00:00.000Z' }, 90 * 24 * 60 * 60 * 1000, now)).toBe(true)
    expect(isFeatureUsedSince({ last_used_at: '2026-04-01T00:00:00.000Z' }, 90 * 24 * 60 * 60 * 1000, now)).toBe(false)
  })

  it.concurrent('routes next action by distribution stage after CLI install', () => {
    expect(nextOnboardingAction({}).feature).toBe('cli_install')
    expect(nextOnboardingAction({
      features: {
        cli_install: { started_at: '2026-08-01T00:00:00.000Z' },
        ota: { stage: 'no_device' },
      },
    })).toEqual({ feature: 'cli_install', stage: 'no_device' })
    expect(nextOnboardingAction({
      features: {
        cli_install: { started_at: '2026-08-01T00:00:00.000Z', succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { started_at: '2026-08-03T00:00:00.000Z', stage: 'testflight' },
      },
    })).toEqual({ feature: 'ota', stage: 'testflight' })
    expect(nextOnboardingAction({
      features: {
        cli_install: { started_at: '2026-08-01T00:00:00.000Z', succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: {
          started_at: '2026-08-03T00:00:00.000Z',
          succeeded_at: '2026-08-04T00:00:00.000Z',
          stage: 'store_live',
        },
      },
    })).toEqual({ feature: 'ota', stage: 'store_live' })
    expect(nextOnboardingAction({
      features: {
        builder: { started_at: '2026-08-01T00:00:00.000Z' },
      },
    })).toEqual({ feature: 'builder', stage: 'no_device' })
    expect(nextOnboardingAction({
      features: {
        cli_install: { started_at: '2026-08-01T00:00:00.000Z', succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { stage: 'play_unknown' },
        builder: { started_at: '2026-08-03T00:00:00.000Z' },
      },
    }).feature).toBe('builder')
    expect(onboardingNextStepMessageKeys({
      features: { builder: { started_at: '2026-08-01T00:00:00.000Z' } },
    })).toEqual({
      titleKey: 'onboarding-next-builder',
      descKey: 'onboarding-next-builder-desc',
    })
  })

  it.concurrent('hides next-step card once store live is reached', () => {
    expect(shouldShowOnboardingNextStep({})).toBe(true)
    expect(shouldShowOnboardingNextStep({
      features: {
        cli_install: { started_at: '2026-08-01T00:00:00.000Z', succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { started_at: '2026-08-03T00:00:00.000Z', succeeded_at: '2026-08-04T00:00:00.000Z', stage: 'store_live' },
      },
    })).toBe(false)
  })

  it.concurrent('builds getting started checklist from ledger stages', () => {
    const empty = buildGettingStartedSteps({})
    expect(empty.every(step => !step.done)).toBe(true)
    expect(shouldShowGettingStartedNav({})).toBe(true)
    expect(gettingStartedProgress(empty)).toEqual({ done: 0, total: 4, percent: 0 })

    const live = buildGettingStartedSteps({
      features: {
        cli_install: { succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { started_at: '2026-08-03T00:00:00.000Z', succeeded_at: '2026-08-04T00:00:00.000Z', stage: 'store_live' },
        builder: { succeeded_at: '2026-08-05T00:00:00.000Z' },
      },
    })
    expect(live.every(step => step.done)).toBe(true)
    expect(shouldShowGettingStartedNav({
      features: {
        ota: { succeeded_at: '2026-08-04T00:00:00.000Z', stage: 'store_live' },
      },
    })).toBe(false)
    expect(gettingStartedProgress(live).percent).toBe(100)
  })

  it.concurrent('marks builder done from extras without keeping the sidebar entry', () => {
    const steps = buildGettingStartedSteps({
      features: {
        cli_install: { succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { stage: 'store_live' },
      },
    }, { builderDone: true })
    expect(steps.find(step => step.id === 'builder')?.done).toBe(true)
    expect(shouldShowGettingStartedNav({
      features: {
        cli_install: { succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { succeeded_at: '2026-08-04T00:00:00.000Z', stage: 'store_live' },
      },
    })).toBe(false)
  })

  it.concurrent('does not mark live_update done from a device stage alone', () => {
    const steps = buildGettingStartedSteps({
      features: {
        cli_install: { succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { stage: 'native_unknown' },
      },
    })
    expect(steps.find(step => step.id === 'cli_install')?.done).toBe(true)
    expect(steps.find(step => step.id === 'live_update')?.done).toBe(false)
    expect(steps.find(step => step.id === 'store_release')?.done).toBe(false)
  })

  it.concurrent('marks install Capgo done when live-update installs exist', () => {
    const steps = buildGettingStartedSteps({
      features: {
        ota: { succeeded_at: '2026-08-04T00:00:00.000Z' },
      },
    })
    expect(steps.find(step => step.id === 'cli_install')?.done).toBe(true)
    expect(steps.find(step => step.id === 'live_update')?.done).toBe(true)
    expect(steps.find(step => step.id === 'store_release')?.done).toBe(false)
    expect(nextOnboardingAction({
      features: { ota: { succeeded_at: '2026-08-04T00:00:00.000Z' } },
    }).feature).toBe('ota')
  })

  it.concurrent('marks store release done after production setup is applied', () => {
    const steps = buildGettingStartedSteps({
      features: { ota: { stage: 'testflight' } },
    }, { storeReleaseValidated: true })
    expect(steps.find(step => step.id === 'store_release')?.done).toBe(true)
    expect(shouldShowGettingStartedNav({
      features: {
        cli_install: { succeeded_at: '2026-08-02T00:00:00.000Z' },
        ota: { succeeded_at: '2026-08-04T00:00:00.000Z', stage: 'testflight' },
      },
    }, { storeReleaseValidated: true })).toBe(false)
  })

  it.concurrent('marks steps proven by real usage as done', () => {
    const pending = parseAppOnboarding({ setup: { todo_list_version: 4, ota_todo_list_version: '1', steps: { ota: { add_channel: { status: 'skipped' }, add_code: { status: 'skipped' } } } } })
    expect(usageProvenStepIds({})).toEqual([])
    expect(withUsageProvenSteps(pending, {})).toBe(pending)

    const devicesOnly = withUsageProvenSteps(pending, { features: { cli_install: { succeeded_at: '2026-09-01T00:00:00.000Z' } } })
    expect(devicesOnly.steps.add_updater?.status).toBe('done')
    expect(devicesOnly.steps.run_device?.status).toBe('done')
    expect(devicesOnly.steps.upload_bundle?.status).toBe('pending')

    const live = withUsageProvenSteps(pending, { features: { ota: { succeeded_at: '2026-09-01T00:00:00.000Z' } } })
    // Skipped steps stay skipped, except the required channel step.
    expect(Object.entries(live.steps).filter(([, step]) => step?.status !== 'done').map(([id]) => id)).toEqual(['add_code'])
    expect(live.steps.add_channel?.status).toBe('done')
  })

  it.concurrent('uses v3 checklist steps for the nav, completed by real usage', () => {
    const steps = Object.fromEntries([
      'login_cli_mcp',
      'add_channel',
      'add_updater',
      'add_code',
      'run_device',
      'upload_bundle',
      'test_update',
    ].map(id => [id, { status: 'done' }]))
    const onboarding = {
      setup: { todo_list_version: 3, outcome: 'completed', steps: { ...steps, test_update: { status: 'pending' } } },
      features: { ota: { succeeded_at: '2026-09-01T00:00:00.000Z', stage: 'store_live' } },
    }
    expect(shouldShowGettingStartedNav(onboarding)).toBe(false)
    expect(shouldShowGettingStartedNav({ ...onboarding, features: { cli_install: { succeeded_at: '2026-09-01T00:00:00.000Z' } } })).toBe(true)
    expect(shouldShowGettingStartedNav({ setup: { todo_list_version: 3, steps } })).toBe(false)
    expect(shouldShowGettingStartedNav({ setup: { todo_list_version: 3, steps: { ...steps, test_update: { status: 'skipped' } } } })).toBe(false)
  })

  it.concurrent('uses v4 OTA checklist steps without falling back to features', () => {
    const setup = { todo_list_version: 4, ota_todo_list_version: '1', steps: { ota: { test_update: { status: 'pending' } } } }
    expect(shouldShowGettingStartedNav({ setup, features: { ota: { stage: 'store_live' } } })).toBe(true)
    expect(shouldShowGettingStartedNav({ setup: { todo_list_version: 4 }, features: {} })).toBe(false)

    const doneSteps = Object.fromEntries([
      'login_cli_mcp',
      'add_channel',
      'add_updater',
      'add_code',
      'run_device',
      'upload_bundle',
      'test_update',
    ].map(id => [id, { status: 'done' }]))
    expect(shouldShowGettingStartedNav({ setup: { ...setup, steps: { ota: doneSteps } }, features: {} })).toBe(false)
    expect(shouldShowGettingStartedNav({ setup: { todo_list_version: 5 }, features: {} })).toBe(false)
  })

  it.concurrent('uses incomplete v4 Builder steps from either platform', () => {
    const doneBuilderSteps = {
      ios: Object.fromEntries(BUILDER_STEP_IDS.ios.map(id => [id, { status: 'done' }])),
      android: Object.fromEntries(BUILDER_STEP_IDS.android.map(id => [id, { status: 'done' }])),
    }
    const setup = {
      todo_list_version: 4,
      builder_todo_list_version: '1',
      paths: ['builder'],
      steps: { builder: doneBuilderSteps },
    }

    expect(shouldShowGettingStartedNav({ setup })).toBe(false)
    expect(shouldShowGettingStartedNav({
      setup: {
        ...setup,
        steps: { builder: { ...doneBuilderSteps, ios: { ...doneBuilderSteps.ios, prepare_profile: { status: 'pending' } } } },
      },
    })).toBe(true)
    expect(shouldShowGettingStartedNav({
      setup: {
        ...setup,
        steps: { builder: { ...doneBuilderSteps, android: { ...doneBuilderSteps.android, prepare_keystore: { status: 'pending' } } } },
      },
    })).toBe(true)
    expect(shouldShowGettingStartedNav({
      setup: {
        ...setup,
        steps: { builder: { ...doneBuilderSteps, ios: { ...doneBuilderSteps.ios, connect_app_store: { status: 'warning' } } } },
      },
    })).toBe(true)
    expect(shouldShowGettingStartedNav({
      setup: {
        ...setup,
        steps: { builder: { ...doneBuilderSteps, ios: { ...doneBuilderSteps.ios, prepare_profile: { status: 'skipped' } } } },
      },
    })).toBe(false)
  })

  it.concurrent('keeps feature-based nav visibility for v1 and v2', () => {
    for (const version of [1, 2]) {
      expect(shouldShowGettingStartedNav({ setup: { todo_list_version: version }, features: {} })).toBe(true)
      expect(shouldShowGettingStartedNav({
        setup: { todo_list_version: version },
        features: { ota: { succeeded_at: '2026-09-01T00:00:00.000Z', stage: 'store_live' } },
      })).toBe(false)
    }
  })
})

describe('checklist-based onboarding redirects', () => {
  function otaSetup(version: number, steps: Record<string, { status: string }>, outcome = 'in_progress') {
    return {
      todo_list_version: version,
      ...(version === 4 ? { ota_todo_list_version: '1', paths: ['ota'] } : {}),
      steps: version === 4 ? { ota: steps } : steps,
      outcome,
    }
  }

  it.concurrent.each([3, 4])('closes v%i setup once a device installed an OTA bundle', (version) => {
    const onboarding = {
      setup: otaSetup(version, {}),
      features: { ota: { succeeded_at: '2026-09-01T00:00:00.000Z', stage: 'store_live' } },
    }
    expect(shouldShowGettingStartedNav(onboarding)).toBe(false)
    expect(shouldSkipOnboardingResume(onboarding)).toBe(true)

    const devicesOnly = { ...onboarding, features: { cli_install: { succeeded_at: '2026-09-01T00:00:00.000Z' } } }
    expect(shouldShowGettingStartedNav(devicesOnly)).toBe(true)
    expect(shouldSkipOnboardingResume(devicesOnly)).toBe(false)
  })

  it.concurrent.each([3, 4])('keeps pending v%i steps open despite a terminal summary', (version) => {
    const steps = Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'done' }]))
    for (const outcome of ['completed', 'skipped']) {
      const onboarding = { setup: otaSetup(version, { ...steps, test_update: { status: 'pending' } }, outcome) }
      expect(shouldShowGettingStartedNav(onboarding)).toBe(true)
      expect(shouldSkipOnboardingResume(onboarding)).toBe(false)
    }
  })

  it.concurrent.each([3, 4])('leaves v%i setup when every step is done or skipped without feature signals', (version) => {
    const steps = Object.fromEntries(APP_ONBOARDING_OTA_V1_STEP_IDS.map(id => [id, { status: 'done' }]))
    for (const status of ['done', 'skipped']) {
      const onboarding = { setup: otaSetup(version, { ...steps, test_update: { status } }) }
      expect(shouldShowGettingStartedNav(onboarding)).toBe(false)
      expect(shouldSkipOnboardingResume(onboarding)).toBe(true)
    }
  })

  it.concurrent.each(['ios', 'android'] as const)('keeps pending v4 Builder %s steps open after an OTA install', (platform) => {
    const steps = {
      ios: Object.fromEntries(BUILDER_STEP_IDS.ios.map(id => [id, { status: 'done' }])),
      android: Object.fromEntries(BUILDER_STEP_IDS.android.map(id => [id, { status: 'done' }])),
    }
    const onboarding = {
      setup: { todo_list_version: 4, builder_todo_list_version: '1', paths: ['builder'], steps: { builder: steps }, outcome: 'completed' },
      features: { ota: { succeeded_at: '2026-09-01T00:00:00.000Z' } },
    }
    steps[platform].successful_cloud_build = { status: 'pending' }
    expect(shouldShowGettingStartedNav(onboarding)).toBe(true)
    expect(shouldSkipOnboardingResume(onboarding)).toBe(false)
    steps[platform].successful_cloud_build = { status: 'skipped' }
    expect(shouldShowGettingStartedNav(onboarding)).toBe(false)
    expect(shouldSkipOnboardingResume(onboarding)).toBe(true)
  })

  it.concurrent.each([3, 4])('honors explicit dismissal of pending v%i setup', (version) => {
    const onboarding = {
      setup: otaSetup(version, {}),
      getting_started_dismissed_at: '2026-09-01T00:00:00.000Z',
    }
    expect(shouldShowGettingStartedNav(onboarding)).toBe(false)
    expect(shouldSkipOnboardingResume(onboarding)).toBe(true)
  })
})

describe('getting started dismiss storage', () => {
  it.concurrent('hides the nav after app-level dismiss', () => {
    const dismissedAt = '2026-08-16T10:00:00.000Z'
    expect(parseAppOnboardingLedger({
      getting_started_dismissed_at: dismissedAt,
      features: { ota: { stage: 'no_device' } },
    }).getting_started_dismissed_at).toBe(dismissedAt)
    expect(shouldShowGettingStartedNav({
      getting_started_dismissed_at: dismissedAt,
    })).toBe(false)
    expect(withGettingStartedDismissed({ features: {} }, dismissedAt)).toEqual({
      features: {},
      getting_started_dismissed_at: dismissedAt,
    })
    expect(withGettingStartedDismissed({ getting_started_dismissed_at: dismissedAt }, '2026-08-16T11:00:00.000Z'))
      .toEqual({ getting_started_dismissed_at: '2026-08-16T11:00:00.000Z' })
    expect(withoutGettingStartedDismissed({
      features: {},
      getting_started_dismissed_at: dismissedAt,
    })).toEqual({ features: {} })
  })

  it.concurrent('skips the login splash after dismiss, shipped bundles, or finished CLI setup', () => {
    expect(shouldSkipOnboardingResume({})).toBe(false)
    expect(shouldSkipOnboardingResume({
      getting_started_dismissed_at: '2026-09-01T00:00:00.000Z',
    })).toBe(true)
    expect(shouldSkipOnboardingResume({
      features: { ota: { started_at: '2026-09-01T00:00:00.000Z' } },
    })).toBe(false)
    expect(shouldSkipOnboardingResume({
      features: { ota: { succeeded_at: '2026-09-01T00:00:00.000Z' } },
    })).toBe(true)
    expect(shouldSkipOnboardingResume({
      setup: { outcome: 'completed' },
    })).toBe(true)
    expect(shouldSkipOnboardingResume({
      outcome: 'completed',
      setup: {},
    })).toBe(true)
    expect(shouldSkipOnboardingResume({
      setup: { outcome: 'skipped' },
    })).toBe(true)
    expect(shouldSkipOnboardingResume({
      setup: { outcome: 'in_progress' },
    })).toBe(false)
  })

  it.concurrent('uses a per-app store-release key', () => {
    expect(storeReleaseValidatedKey('user-1', 'com.demo.app')).toBe('capgo.gettingStarted.storeRelease.user-1.com.demo.app')
  })
})