tests/android-onboarding-progress.unit.test.ts
Rama main · réplica de lectura del repositorio original.
import type { AndroidOnboardingProgress } from '../cli/src/build/onboarding/android/types.ts'
import { describe, expect, it } from 'vitest'
import { getAndroidResumeStep } from '../cli/src/build/onboarding/android/progress.ts'
// ─── Test fixture helpers ───────────────────────────────────────────
//
// Built compositionally so each test makes the minimum-relevant
// progress shape and the failure-localization on assertions stays
// clear: if `withFullKeystore(emptyProgress())` resumes to the wrong
// step, the offending field is unambiguous.
function emptyProgress(): AndroidOnboardingProgress {
return {
platform: 'android',
appId: 'com.test.app',
startedAt: '2026-05-21T00:00:00.000Z',
completedSteps: {},
}
}
function withFullKeystore(p: AndroidOnboardingProgress): AndroidOnboardingProgress {
// `keystoreFullyValid` requires the marker AND all three ephemeral
// fields. Helper sets them all so resume always passes the keystore
// gate and tests focus on Phase 2+.
return {
...p,
keystoreAlias: 'release',
keystoreStorePassword: 'pw',
_keystoreBase64: 'base64-data',
completedSteps: {
...p.completedSteps,
keystoreReady: {
keystorePath: 'release.p12',
alias: 'release',
isGenerated: true,
},
},
}
}
function withGoogleSignIn(p: AndroidOnboardingProgress): AndroidOnboardingProgress {
return {
...p,
_oauthRefreshToken: 'refresh-token',
completedSteps: {
...p.completedSteps,
googleSignInComplete: {
email: 'user@example.com',
googleSubject: 'subject-123',
scope: 'androidpublisher cloud-platform',
},
},
}
}
function withPackageChosen(p: AndroidOnboardingProgress): AndroidOnboardingProgress {
return {
...p,
completedSteps: {
...p.completedSteps,
androidPackageChosen: {
packageName: 'com.test.app',
source: 'gradle',
},
},
}
}
// ─── Base routing ───────────────────────────────────────────────────
describe('getAndroidResumeStep — base routing', () => {
it('returns welcome for null progress', () => {
expect(getAndroidResumeStep(null)).toBe('welcome')
})
it('returns keystore-method-select when keystore is not started', () => {
expect(getAndroidResumeStep(emptyProgress())).toBe('keystore-method-select')
})
})
// ─── Legacy progress (backward compatibility contract) ──────────────
//
// Progress files created before the service-account fork existed have
// no `serviceAccountMethod` field. The compatibility contract: route
// them onto the existing OAuth path so in-flight onboardings continue
// where they were. Don't drop legacy users into the new fork.
describe('getAndroidResumeStep — legacy progress (no serviceAccountMethod)', () => {
it('legacy progress with only keystore done routes to google-sign-in', () => {
// No serviceAccountMethod → fall through to the OAuth-path rules.
// !googleSignInComplete → google-sign-in. This is the contract.
const p = withFullKeystore(emptyProgress())
expect(getAndroidResumeStep(p)).toBe('google-sign-in')
})
it('legacy progress past google-sign-in continues to play-developer-id-input', () => {
const p = withGoogleSignIn(withFullKeystore(emptyProgress()))
expect(getAndroidResumeStep(p)).toBe('play-developer-id-input')
})
it('re-runs google-sign-in if the marker exists but the refresh token is missing', () => {
// Defensive: a partial completion that lost the refresh token can't
// mint new access tokens, so the rest of the OAuth chain would fail.
// Treat it as never-signed-in.
const p = withFullKeystore(emptyProgress())
p.completedSteps.googleSignInComplete = {
email: 'user@example.com',
googleSubject: 'subject-123',
scope: 'androidpublisher cloud-platform',
}
// Note: no _oauthRefreshToken
expect(getAndroidResumeStep(p)).toBe('google-sign-in')
})
})
// ─── Import path (serviceAccountMethod === 'existing') ──────────────
describe('getAndroidResumeStep — import path', () => {
it('routes to android-package-select first if no package chosen yet', () => {
// Package name is needed for the edits.insert validation probe.
// Until we have it, the import path can't proceed past file pick.
const p = withFullKeystore(emptyProgress())
p.serviceAccountMethod = 'existing'
expect(getAndroidResumeStep(p)).toBe('android-package-select')
})
it('routes to sa-json-existing-path after package chosen but no file picked', () => {
const p = withPackageChosen(withFullKeystore(emptyProgress()))
p.serviceAccountMethod = 'existing'
expect(getAndroidResumeStep(p)).toBe('sa-json-existing-path')
})
it('routes to sa-json-validating after file picked but not yet accepted', () => {
const p = withPackageChosen(withFullKeystore(emptyProgress()))
p.serviceAccountMethod = 'existing'
p.serviceAccountJsonPath = '/path/to/sa.json'
expect(getAndroidResumeStep(p)).toBe('sa-json-validating')
})
it('routes to saving-credentials once the SA key bytes are accepted', () => {
// `_serviceAccountKeyBase64` is set by EITHER a successful validation
// OR the user picking "save anyway" on the failure recovery screen.
// Both should converge to saving-credentials so we don't re-run
// validation pointlessly on resume.
const p = withPackageChosen(withFullKeystore(emptyProgress()))
p.serviceAccountMethod = 'existing'
p.serviceAccountJsonPath = '/path/to/sa.json'
p._serviceAccountKeyBase64 = 'base64-sa-bytes'
expect(getAndroidResumeStep(p)).toBe('saving-credentials')
})
it('honors save-anyway resume even with serviceAccountValidationSkipped=true', () => {
// The skipped flag drives a banner log at save time; it must NOT
// affect routing. The key bytes are the source of truth.
const p = withPackageChosen(withFullKeystore(emptyProgress()))
p.serviceAccountMethod = 'existing'
p.serviceAccountJsonPath = '/path/to/sa.json'
p._serviceAccountKeyBase64 = 'base64-sa-bytes'
p.serviceAccountValidationSkipped = true
expect(getAndroidResumeStep(p)).toBe('saving-credentials')
})
})
// ─── Generate path (serviceAccountMethod === 'generate') ────────────
describe('getAndroidResumeStep — generate path', () => {
it("'generate' explicitly set falls through to the OAuth-path rules", () => {
const p = withFullKeystore(emptyProgress())
p.serviceAccountMethod = 'generate'
// Same as the legacy case but with an explicit marker — should still
// land on google-sign-in because no OAuth steps are done.
expect(getAndroidResumeStep(p)).toBe('google-sign-in')
})
it("'generate' continues to play-developer-id-input after sign-in", () => {
const p = withGoogleSignIn(withFullKeystore(emptyProgress()))
p.serviceAccountMethod = 'generate'
expect(getAndroidResumeStep(p)).toBe('play-developer-id-input')
})
})