// tests/main/services/update-service-redteam-r1-3.test.ts // 자동 업데이트 회귀 테스트 (가짜 포트 주입): // - 원격 정책을 체크마다 다시 받는다 (킬 스위치·rollout 이 실행 중 클라이언트에 반영) // - '나중에' 이후에도 새 버전·필수 격상·간격 경과 시 다시 묻는다 // - evaluateUpdateOffer 게이팅 순서 import { describe, it, expect, beforeEach, vi } from 'vitest' import { DEFAULT_UPDATE_POLICY, evaluateUpdateOffer, shouldPromptForUpdate, UPDATE_REPROMPT_INTERVAL_MS, type UpdatePolicy, } from '../../../src/main/update-policy' import type { ConsentAnswer, PolicySource, UpdatePrompter, UpdaterEventHandlers, UpdaterPort, } from '../../../src/main/services/UpdateService' vi.mock('../../../src/main/services/LoggerService', () => ({ getLogger: () => ({ info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }), })) const store = vi.hoisted(() => ({ values: {} as Record })) vi.mock('../../../src/main/services/ConfigService', () => ({ configGet: vi.fn((key: string) => store.values[key]), configSet: vi.fn((key: string, value: unknown) => { store.values[key] = value }), configGetAll: vi.fn(() => store.values), })) type ServiceModule = typeof import('../../../src/main/services/UpdateService') let UpdateService: ServiceModule['UpdateService'] function policy(overrides: Partial = {}): UpdatePolicy { return { ...DEFAULT_UPDATE_POLICY, channels: { ...DEFAULT_UPDATE_POLICY.channels }, ...overrides } } function harness(opts: { answers?: ConsentAnswer[]; policies?: UpdatePolicy[] } = {}) { const answers = [...(opts.answers ?? [])] const policies = [...(opts.policies ?? [policy()])] let clock = 1_000_000 let handlers: UpdaterEventHandlers | null = null const updater: UpdaterPort & { checks: number; downloads: number } = { checks: 0, downloads: 0, checkForUpdates: vi.fn(async () => { updater.checks++ }), downloadUpdate: vi.fn(async () => { updater.downloads++ }), quitAndInstall: vi.fn(), setChannel: vi.fn(), setDifferentialDisabled: vi.fn(), } const policySource: PolicySource & { loads: number } = { loads: 0, load: vi.fn(async () => { policySource.loads++ return policies.length > 1 ? (policies.shift() as UpdatePolicy) : policies[0] }), } const consents: string[] = [] const prompter: UpdatePrompter = { askConsent: vi.fn(async ({ version }) => { consents.push(version) return answers.shift() ?? 'later' }), askRestart: vi.fn(async () => 'later' as const), } const service = new UpdateService({ createUpdater: (h) => { handlers = h return updater }, policySource, prompter, currentVersion: () => '1.9.0', now: () => clock, }) service.initialize() service.dispose() // 타이머는 쓰지 않는다 — 테스트가 직접 호출 return { service, updater, policySource, consents, handlers: () => handlers as UpdaterEventHandlers, advance: (ms: number) => { clock += ms }, } } beforeEach(async () => { vi.resetModules() store.values = { updateDeviceId: 'device-1' } UpdateService = (await import('../../../src/main/services/UpdateService')).UpdateService }) describe('UpdateService — 원격 정책 갱신', () => { it('체크할 때마다 정책을 다시 받고, 킬 스위치가 켜지면 확인을 멈춘다', async () => { const h = harness({ policies: [policy(), policy({ killSwitch: true })] }) await h.service.checkForUpdates() expect(h.updater.checks).toBe(1) await h.service.checkForUpdates() expect(h.policySource.loads).toBe(2) expect(h.updater.checks).toBe(1) }) it('킬 스위치가 켜진 뒤 도착한 update-available 도 무시한다', async () => { const h = harness({ policies: [policy({ killSwitch: true })] }) await h.service.checkForUpdates() await h.service.handleUpdateAvailable({ version: '1.9.1' }) expect(h.consents).toEqual([]) }) }) describe('UpdateService — 다시 묻기', () => { it("'나중에' 이후 새 버전이 나오면 다시 묻는다", async () => { const h = harness({ answers: ['later', 'later'] }) await h.service.checkForUpdates() await h.service.handleUpdateAvailable({ version: '1.9.1' }) await h.service.handleUpdateAvailable({ version: '1.9.2' }) expect(h.consents).toEqual(['1.9.1', '1.9.2']) }) it('같은 버전은 간격 안에서는 다시 묻지 않고, 간격이 지나면 다시 묻는다', async () => { const h = harness({ answers: ['later', 'later'] }) await h.service.checkForUpdates() await h.service.handleUpdateAvailable({ version: '1.9.1' }) await h.service.handleUpdateAvailable({ version: '1.9.1' }) expect(h.consents).toEqual(['1.9.1']) h.advance(UPDATE_REPROMPT_INTERVAL_MS) await h.service.handleUpdateAvailable({ version: '1.9.1' }) expect(h.consents).toEqual(['1.9.1', '1.9.1']) }) it('필수로 격상되면 간격과 무관하게 다시 묻는다', async () => { const h = harness({ answers: ['later', 'download'], policies: [policy(), policy({ minimumSupportedVersion: '1.9.1' })], }) await h.service.checkForUpdates() await h.service.handleUpdateAvailable({ version: '1.9.1' }) await h.service.checkForUpdates() await h.service.handleUpdateAvailable({ version: '1.9.1' }) expect(h.consents).toEqual(['1.9.1', '1.9.1']) expect(h.updater.downloads).toBe(1) }) it('수락한 다운로드가 실패하면 다음 체크에서 다시 묻는다', async () => { const h = harness({ answers: ['download', 'later'] }) await h.service.checkForUpdates() await h.service.handleUpdateAvailable({ version: '1.9.1' }) expect(h.updater.downloads).toBe(1) h.handlers().onError('network down') await h.service.handleUpdateAvailable({ version: '1.9.1' }) expect(h.consents).toEqual(['1.9.1', '1.9.1']) }) it("'이 버전 건너뛰기' 는 그 버전을 다시 묻지 않는다", async () => { const h = harness({ answers: ['skip'] }) await h.service.checkForUpdates() await h.service.handleUpdateAvailable({ version: '1.9.1' }) h.advance(UPDATE_REPROMPT_INTERVAL_MS * 2) await h.service.handleUpdateAvailable({ version: '1.9.1' }) expect(h.consents).toEqual(['1.9.1']) expect(store.values.skippedUpdateVersion).toBe('1.9.1') }) }) describe('evaluateUpdateOffer 게이팅 순서', () => { const base = { channel: 'latest' as const, currentVersion: '1.9.0', skippedVersion: null, deviceId: 'device-1', } it('킬 스위치가 가장 먼저다', () => { const offer = evaluateUpdateOffer({ ...base, policy: policy({ killSwitch: true }), targetVersion: '1.9.1' }) expect(offer).toMatchObject({ action: 'ignore', reason: 'kill-switch' }) }) it('stable 채널은 prerelease 를 거부한다', () => { const offer = evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.10.0-beta.1' }) expect(offer).toMatchObject({ action: 'ignore', reason: 'channel' }) }) it('필수 업데이트는 건너뛰기·rollout 을 무시한다', () => { const offer = evaluateUpdateOffer({ ...base, policy: policy({ minimumSupportedVersion: '1.9.1', stagingPercentage: 0 }), targetVersion: '1.9.1', skippedVersion: '1.9.1', }) expect(offer).toMatchObject({ action: 'prompt', decision: { mandatory: true } }) }) it('건너뛴 버전 / rollout 밖은 무시한다', () => { expect( evaluateUpdateOffer({ ...base, policy: policy(), targetVersion: '1.9.1', skippedVersion: '1.9.1' }), ).toMatchObject({ action: 'ignore', reason: 'skipped' }) expect( evaluateUpdateOffer({ ...base, policy: policy({ stagingPercentage: 0 }), targetVersion: '1.9.1' }), ).toMatchObject({ action: 'ignore', reason: 'rollout' }) }) it('강제 설치 하한 미만이면 대화상자 없이 다운로드한다', () => { const offer = evaluateUpdateOffer({ ...base, policy: policy({ forceInstallBelow: '1.9.1' }), targetVersion: '2.0.0' }) expect(offer).toMatchObject({ action: 'force-download', isMajorUpgrade: true }) }) it('shouldPromptForUpdate: 기록 없음 / 새 버전 / 필수 격상 / 간격 경과', () => { const last = { version: '1.9.1', mandatory: false, at: 0 } expect(shouldPromptForUpdate(null, { version: '1.9.1', mandatory: false }, 0)).toBe(true) expect(shouldPromptForUpdate(last, { version: '1.9.2', mandatory: false }, 1)).toBe(true) expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: true }, 1)).toBe(true) expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, 1)).toBe(false) expect(shouldPromptForUpdate(last, { version: '1.9.1', mandatory: false }, UPDATE_REPROMPT_INTERVAL_MS)).toBe(true) }) })