// tests/main/update-policy.test.ts // 업데이트 정책 순수 로직 테스트: 버전 비교, 메이저/증분 판단, 강제 업데이트, // 채널 게이팅, staged rollout. import { describe, it, expect } from 'vitest' import { DEFAULT_UPDATE_POLICY, compareVersions, decideUpdate, isChannelAcceptable, isWithinRollout, parseUpdatePolicy, rolloutBucket, type UpdatePolicy, } from '../../src/main/update-policy' import { channelMetadataName, isUpdateChannel } from '../../src/main/update-feed' const policy: UpdatePolicy = { ...DEFAULT_UPDATE_POLICY, minimumSupportedVersion: '1.0.0', forceInstallBelow: '0.3.0', fullInstallOnMajorChange: true, fullInstallVersionGap: 3, stagingPercentage: 100, } describe('compareVersions', () => { it('주/부/패치를 순서대로 비교한다', () => { expect(compareVersions('1.2.3', '1.2.3')).toBe(0) expect(compareVersions('1.2.3', '1.2.4')).toBe(-1) expect(compareVersions('1.3.0', '1.2.9')).toBe(1) expect(compareVersions('2.0.0', '1.99.99')).toBe(1) }) it('prerelease는 같은 코어의 stable보다 낮다', () => { expect(compareVersions('1.0.0-alpha', '1.0.0')).toBe(-1) expect(compareVersions('1.1.0-beta.2', '1.1.0-beta.10')).toBe(-1) }) it('v 접두와 build metadata를 허용한다', () => { expect(compareVersions('v1.1.0', '1.1.0+build.7')).toBe(0) }) it('파싱 실패 시 null', () => { expect(compareVersions('not-a-version', '1.0.0')).toBeNull() }) }) describe('decideUpdate', () => { it('최소 지원 버전 미만이면 강제 업데이트', () => { const decision = decideUpdate(policy, '0.5.0', '1.1.0') expect(decision.mandatory).toBe(true) expect(decision.reason).toBe('below-minimum-supported') }) it('forceInstallBelow 미만이면 강제 사유가 force-install (더 강한 조건이 우선)', () => { const decision = decideUpdate(policy, '0.2.1-alpha', '1.1.0') expect(decision.mandatory).toBe(true) expect(decision.reason).toBe('below-force-install') }) it('최소 지원 이상이면 일반 업데이트', () => { const decision = decideUpdate(policy, '1.1.0', '1.2.0') expect(decision.mandatory).toBe(false) expect(decision.reason).toBe('standard') expect(decision.forceFull).toBe(false) }) it('major 승격이면 full 다운로드', () => { const decision = decideUpdate(policy, '1.9.0', '2.0.0') expect(decision.forceFull).toBe(true) expect(decision.mandatory).toBe(false) }) it('minor 갭이 임계 이상이면 full 다운로드', () => { const decision = decideUpdate(policy, '1.0.0', '1.5.0') expect(decision.forceFull).toBe(true) }) it('major 정책이 꺼져 있으면 major 승격도 차분 가능', () => { const relaxed = { ...policy, fullInstallOnMajorChange: false } expect(decideUpdate(relaxed, '1.9.0', '2.0.0').forceFull).toBe(false) }) it('현재 버전을 파싱할 수 없으면 전체 설치', () => { expect(decideUpdate(policy, 'garbage', '1.2.0').forceFull).toBe(true) }) }) describe('isChannelAcceptable', () => { it('stable 채널은 prerelease를 받지 않는다', () => { expect(isChannelAcceptable(false, '1.0.0', '1.1.0-beta')).toBe(false) expect(isChannelAcceptable(false, '1.0.0', '1.1.0')).toBe(true) }) it('beta 채널은 prerelease를 받는다', () => { expect(isChannelAcceptable(true, '1.0.0', '1.1.0-beta')).toBe(true) }) it('더 높은 버전이 아니면 거부한다', () => { expect(isChannelAcceptable(false, '1.2.0', '1.1.0')).toBe(false) }) }) describe('staged rollout', () => { it('deviceId에 대해 결정적 버킷을 돌려준다', () => { expect(rolloutBucket('device-a', '1.1.0')).toBe(rolloutBucket('device-a', '1.1.0')) expect(rolloutBucket('device-a', '1.1.0')).toBeLessThan(100) }) it('100%는 모두 포함, 0%는 모두 제외', () => { const standard = { mandatory: false, forceFull: false, reason: 'standard' as const } expect(isWithinRollout({ ...policy, stagingPercentage: 100 }, 'd', '1.1.0', standard)).toBe(true) expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', standard)).toBe(false) }) it('강제 업데이트는 rollout을 무시한다', () => { const forced = { mandatory: true, forceFull: true, reason: 'below-force-install' as const } expect(isWithinRollout({ ...policy, stagingPercentage: 0 }, 'd', '1.1.0', forced)).toBe(true) }) }) describe('parseUpdatePolicy', () => { it('유효한 필드를 반영하고 잘못된 필드는 기본값 유지', () => { const parsed = parseUpdatePolicy({ schemaVersion: 1, defaultChannel: 'beta', minimumSupportedVersion: '1.2.0', forceInstallBelow: null, fullInstallOnMajorChange: false, fullInstallVersionGap: 5, stagingPercentage: 250, killSwitch: true, channels: { latest: { allowPrerelease: false }, rogue: {} }, }) expect(parsed.defaultChannel).toBe('beta') expect(parsed.minimumSupportedVersion).toBe('1.2.0') expect(parsed.forceInstallBelow).toBeNull() expect(parsed.fullInstallOnMajorChange).toBe(false) expect(parsed.fullInstallVersionGap).toBe(5) expect(parsed.stagingPercentage).toBe(100) expect(parsed.killSwitch).toBe(true) expect(parsed.channels.beta.allowPrerelease).toBe(DEFAULT_UPDATE_POLICY.channels.beta.allowPrerelease) }) it('잘못된 입력은 기본 정책', () => { expect(parseUpdatePolicy(null)).toEqual(DEFAULT_UPDATE_POLICY) expect(parseUpdatePolicy('nope')).toEqual(DEFAULT_UPDATE_POLICY) }) }) describe('update-feed helpers', () => { it('채널별 metadata 파일명', () => { expect(channelMetadataName('latest')).toBe('latest.yml') expect(channelMetadataName('beta')).toBe('beta.yml') expect(channelMetadataName('alpha')).toBe('alpha.yml') }) it('채널 식별', () => { expect(isUpdateChannel('latest')).toBe(true) expect(isUpdateChannel('canary')).toBe(false) }) })