/** * Тесты для IPC validation layer. * * Этот слой — security-boundary между renderer и main. Если он сломается, * compromised renderer сможет писать в стор NaN, отрицательные, Infinity, * сверхдлинные строки или undefined-enum'ы. Поэтому покрытие важно для: * * 1. Тип-проверок (строка/число/булево/массив) * 2. Range-checks (reps ∈ [1,9999], minutes ∈ [1,1440] и т.д.) * 3. Enum allowlist (theme/lang/notify-mode/stat) * 4. Edge cases: NaN, Infinity, MAX_SAFE_INTEGER, 0, отрицательные, длина строк * 5. Partial-patch semantics (отсутствие поля ≠ невалидное значение) * 6. Сложный nested case: quietHours с HH:MM regex и dedup days */ import { describe, expect, it } from 'vitest' import { validateExerciseInput, validateExercisePatch, validateChallengeInput, validateChallengePatch, validateSettingsPatch, validateId, validateActualReps, validateSnoozeMinutes } from './validate' const validExercise = { name: 'Push-ups', reps: 10, intervalMinutes: 30, icon: 'Dumbbell', enabled: true } describe('validateExerciseInput', () => { it('accepts a fully-formed valid input', () => { expect(validateExerciseInput(validExercise)).toEqual(validExercise) }) it('rejects non-objects', () => { expect(validateExerciseInput(null)).toBeNull() expect(validateExerciseInput(undefined)).toBeNull() expect(validateExerciseInput('string')).toBeNull() expect(validateExerciseInput(42)).toBeNull() expect(validateExerciseInput([])).toBeNull() // arrays not allowed }) it('rejects missing required fields', () => { expect(validateExerciseInput({ ...validExercise, name: undefined })).toBeNull() expect(validateExerciseInput({ ...validExercise, reps: undefined })).toBeNull() expect( validateExerciseInput({ ...validExercise, intervalMinutes: undefined }) ).toBeNull() }) it('rejects out-of-range reps', () => { expect(validateExerciseInput({ ...validExercise, reps: 0 })).toBeNull() expect(validateExerciseInput({ ...validExercise, reps: -1 })).toBeNull() expect(validateExerciseInput({ ...validExercise, reps: 10_000 })).toBeNull() expect(validateExerciseInput({ ...validExercise, reps: NaN })).toBeNull() expect( validateExerciseInput({ ...validExercise, reps: Infinity }) ).toBeNull() }) it('truncates reps with Math.trunc (5.7 → 5)', () => { const r = validateExerciseInput({ ...validExercise, reps: 5.7 }) expect(r?.reps).toBe(5) }) it('rejects out-of-range intervalMinutes (> 24h)', () => { expect( validateExerciseInput({ ...validExercise, intervalMinutes: 0 }) ).toBeNull() expect( validateExerciseInput({ ...validExercise, intervalMinutes: 1441 }) ).toBeNull() expect( validateExerciseInput({ ...validExercise, intervalMinutes: -1 }) ).toBeNull() }) it('rejects empty name', () => { expect(validateExerciseInput({ ...validExercise, name: '' })).toBeNull() }) it('rejects name longer than MAX_STR_LEN (200)', () => { expect( validateExerciseInput({ ...validExercise, name: 'x'.repeat(201) }) ).toBeNull() }) it('accepts name exactly at MAX_STR_LEN', () => { const r = validateExerciseInput({ ...validExercise, name: 'x'.repeat(200) }) expect(r?.name).toHaveLength(200) }) it('defaults icon to Activity if missing', () => { const { icon: _ignored, ...rest } = validExercise void _ignored expect(validateExerciseInput(rest)?.icon).toBe('Activity') }) it('defaults enabled to true if missing', () => { const { enabled: _ignored, ...rest } = validExercise void _ignored expect(validateExerciseInput(rest)?.enabled).toBe(true) }) // Дизайн validateExerciseInput: required-поля (name/reps/intervalMinutes) // строгие — невалидное значение reject'ит весь input. Optional-поля // (icon/enabled) lenient — невалидное молча подменяется дефолтом. Это // фиксирует контракт: malicious renderer не сможет создать запись с // reps=-1, но если он пришлёт `enabled: 'yes'`, получит просто enabled=true. it('coerces invalid enabled to true (lenient default for optional fields)', () => { expect( validateExerciseInput({ ...validExercise, enabled: 'yes' })?.enabled ).toBe(true) expect( validateExerciseInput({ ...validExercise, enabled: 1 })?.enabled ).toBe(true) }) // А вот в patch optional-поля строгие — нет defaults, есть `if (v === // undefined) return null`. Это правильнее: если renderer пришёл с патчем, // в котором есть поле, оно должно быть валидным. it('strict patch: rejects invalid enabled in patch (unlike input)', () => { expect(validateExercisePatch({ enabled: 'yes' })).toBeNull() expect(validateExercisePatch({ enabled: 1 })).toBeNull() }) it('rejects non-string name', () => { expect(validateExerciseInput({ ...validExercise, name: 42 })).toBeNull() expect(validateExerciseInput({ ...validExercise, name: null })).toBeNull() }) }) describe('validateExercisePatch', () => { it('accepts an empty patch (no-op update)', () => { expect(validateExercisePatch({})).toEqual({}) }) it('accepts partial patches', () => { expect(validateExercisePatch({ reps: 12 })).toEqual({ reps: 12 }) expect(validateExercisePatch({ name: 'New' })).toEqual({ name: 'New' }) expect(validateExercisePatch({ enabled: false })).toEqual({ enabled: false }) }) it('rejects patch with a single invalid field', () => { // Patch is all-or-nothing: one bad field rejects the whole patch. expect(validateExercisePatch({ name: 'OK', reps: -1 })).toBeNull() expect(validateExercisePatch({ name: '', reps: 10 })).toBeNull() }) it('rejects non-object', () => { expect(validateExercisePatch(null)).toBeNull() expect(validateExercisePatch([])).toBeNull() }) it('accepts nextFireAt and lastDoneAt with valid ranges', () => { expect(validateExercisePatch({ nextFireAt: 0 })).toEqual({ nextFireAt: 0 }) expect(validateExercisePatch({ lastDoneAt: 1_000_000_000_000 })).toEqual({ lastDoneAt: 1_000_000_000_000 }) }) it('rejects negative timestamps', () => { expect(validateExercisePatch({ nextFireAt: -1 })).toBeNull() expect(validateExercisePatch({ lastDoneAt: -1 })).toBeNull() }) it('rejects NaN/Infinity timestamps', () => { expect(validateExercisePatch({ nextFireAt: NaN })).toBeNull() expect(validateExercisePatch({ nextFireAt: Infinity })).toBeNull() }) }) describe('validateChallengeInput', () => { const valid = { name: 'Deaths → squats', gameId: 'dota2', stat: 'deaths' as const, multiplier: 3, exerciseName: 'Приседания', icon: 'Activity', enabled: true } it('accepts valid input', () => { expect(validateChallengeInput(valid)).toEqual(valid) }) it('rejects unknown stat', () => { expect(validateChallengeInput({ ...valid, stat: 'pizza' })).toBeNull() }) it('accepts all valid stats', () => { const stats = ['deaths', 'kills', 'assists', 'last_hits', 'denies', 'duration_min'] for (const stat of stats) { expect(validateChallengeInput({ ...valid, stat })).not.toBeNull() } }) it('rejects negative multiplier', () => { expect(validateChallengeInput({ ...valid, multiplier: -1 })).toBeNull() }) it('rejects multiplier > 1000', () => { expect(validateChallengeInput({ ...valid, multiplier: 1001 })).toBeNull() }) it('accepts zero multiplier (legitimate "disable" semantics)', () => { expect(validateChallengeInput({ ...valid, multiplier: 0 })?.multiplier).toBe(0) }) it('accepts fractional multiplier (e.g. 0.5×)', () => { expect(validateChallengeInput({ ...valid, multiplier: 0.5 })?.multiplier).toBe(0.5) }) }) describe('validateChallengePatch', () => { it('accepts empty patch', () => { expect(validateChallengePatch({})).toEqual({}) }) it('rejects unknown stat in patch', () => { expect(validateChallengePatch({ stat: 'mana' })).toBeNull() }) }) describe('validateSettingsPatch', () => { it('accepts empty patch', () => { expect(validateSettingsPatch({})).toEqual({}) }) it('accepts each boolean toggle independently', () => { expect(validateSettingsPatch({ globalEnabled: false })).toEqual({ globalEnabled: false }) expect(validateSettingsPatch({ soundEnabled: true })).toEqual({ soundEnabled: true }) }) it('rejects unknown theme', () => { expect(validateSettingsPatch({ theme: 'sepia' })).toBeNull() }) it('accepts all valid themes', () => { expect(validateSettingsPatch({ theme: 'light' })?.theme).toBe('light') expect(validateSettingsPatch({ theme: 'dark' })?.theme).toBe('dark') expect(validateSettingsPatch({ theme: 'system' })?.theme).toBe('system') }) it('rejects unknown language', () => { expect(validateSettingsPatch({ language: 'fr' })).toBeNull() }) it('rejects unknown notification mode', () => { expect(validateSettingsPatch({ notificationMode: 'sms' })).toBeNull() }) it('rejects out-of-range snoozeMinutes', () => { expect(validateSettingsPatch({ snoozeMinutes: 0 })).toBeNull() expect(validateSettingsPatch({ snoozeMinutes: 1441 })).toBeNull() expect(validateSettingsPatch({ snoozeMinutes: -5 })).toBeNull() }) describe('quietHours subobject', () => { const baseQh = { enabled: true, from: '22:00', to: '08:00', days: [0, 1, 2, 3, 4, 5, 6] } it('accepts a valid quietHours', () => { expect(validateSettingsPatch({ quietHours: baseQh })?.quietHours).toEqual( baseQh ) }) it('rejects non-object quietHours', () => { expect(validateSettingsPatch({ quietHours: 'always' })).toBeNull() expect(validateSettingsPatch({ quietHours: null })).toBeNull() }) it('rejects malformed HH:MM', () => { expect( validateSettingsPatch({ quietHours: { ...baseQh, from: '2500' } }) ).toBeNull() expect( validateSettingsPatch({ quietHours: { ...baseQh, to: 'bedtime' } }) ).toBeNull() expect( validateSettingsPatch({ quietHours: { ...baseQh, from: '8' } }) ).toBeNull() }) it('accepts HH:MM with 1-digit hour (9:30)', () => { // Regex is /^\d{1,2}:\d{2}$/ — допускаем «9:30», парсер сам разберётся. const r = validateSettingsPatch({ quietHours: { ...baseQh, from: '9:30' } }) expect(r?.quietHours?.from).toBe('9:30') }) it('dedupes days array', () => { const r = validateSettingsPatch({ quietHours: { ...baseQh, days: [1, 2, 2, 3, 1] } }) expect(r?.quietHours?.days).toEqual([1, 2, 3]) }) it('rejects out-of-range day (7)', () => { expect( validateSettingsPatch({ quietHours: { ...baseQh, days: [0, 7] } }) ).toBeNull() }) it('rejects negative day', () => { expect( validateSettingsPatch({ quietHours: { ...baseQh, days: [-1] } }) ).toBeNull() }) it('rejects non-array days', () => { expect( validateSettingsPatch({ quietHours: { ...baseQh, days: 'all' } }) ).toBeNull() }) it('accepts empty days array (window effectively disabled)', () => { const r = validateSettingsPatch({ quietHours: { ...baseQh, days: [] } }) expect(r?.quietHours?.days).toEqual([]) }) }) }) describe('validateId', () => { it('accepts reasonable id strings', () => { expect(validateId('abc')).toBe('abc') expect(validateId('uuid-v4-style-thing-123')).toBe('uuid-v4-style-thing-123') }) it('rejects non-strings', () => { expect(validateId(42)).toBeNull() expect(validateId(null)).toBeNull() expect(validateId(undefined)).toBeNull() expect(validateId({})).toBeNull() }) it('rejects empty string', () => { expect(validateId('')).toBeNull() }) it('rejects strings longer than 64 chars', () => { expect(validateId('x'.repeat(65))).toBeNull() }) }) describe('validateActualReps', () => { it('returns undefined for undefined/null (means: use planned reps)', () => { expect(validateActualReps(undefined)).toBeUndefined() expect(validateActualReps(null)).toBeUndefined() }) it('accepts zero (partial completion = "did 0 of 10")', () => { expect(validateActualReps(0)).toBe(0) }) it('accepts large values up to cap', () => { expect(validateActualReps(100_000)).toBe(100_000) }) it('rejects negative', () => { expect(validateActualReps(-1)).toBeUndefined() }) it('rejects values above cap', () => { expect(validateActualReps(100_001)).toBeUndefined() }) it('rejects NaN/Infinity', () => { expect(validateActualReps(NaN)).toBeUndefined() expect(validateActualReps(Infinity)).toBeUndefined() }) }) describe('validateSnoozeMinutes', () => { it('accepts valid minutes', () => { expect(validateSnoozeMinutes(15)).toBe(15) expect(validateSnoozeMinutes(1)).toBe(1) expect(validateSnoozeMinutes(1440)).toBe(1440) }) it('rejects 0 and above 24h', () => { expect(validateSnoozeMinutes(0)).toBeNull() expect(validateSnoozeMinutes(1441)).toBeNull() }) it('rejects non-numbers', () => { expect(validateSnoozeMinutes('15')).toBeNull() expect(validateSnoozeMinutes(null)).toBeNull() }) })