import '@angular/compiler'; import { FormBuilder } from '@angular/forms'; import { buildChallengeFormGroup, defaultMinimumPoints, syncChallengeForm, } from '../../frontend/src/app/features/admin/challenges/challenge-form-modal.component'; import { ChallengeFormDefaults, ChallengeFormFileItem, ChallengeStagedFileUi, buildInitialFiles, defaultMinimumPoints as pureDefault, deriveMinimumPoints, isValidIpOrHostname, mapServerFieldErrors, prefillChallengeFormDefaults, toCreateBody, validateChallengeForm, } from '../../frontend/src/app/features/admin/challenges/challenge-form.pure'; import { AdminChallengeDetail } from '../../frontend/src/app/core/services/admin.service'; function makeDetail(overrides: Partial = {}): AdminChallengeDetail { return { id: 'd1', name: 'Sample', descriptionMd: '# hi', categoryId: 'cat-1', categoryAbbreviation: 'CRY', categoryIconPath: '/uploads/icons/CRY.png', difficulty: 'MEDIUM', initialPoints: 200, minimumPoints: 100, decaySolves: 5, flag: 'flag{X}', protocol: 'NC', port: 1337, ipAddress: '127.0.0.1', enabled: true, createdAt: '2026-07-22T00:00:00.000Z', updatedAt: '2026-07-22T00:00:00.000Z', files: [ { id: 'f1', originalFilename: 'manual.txt', mimeType: 'text/plain', sizeBytes: 42, createdAt: '2026-07-22T00:00:00.000Z', }, ], ...overrides, }; } function makeDefaults(overrides: Partial = {}): ChallengeFormDefaults { return { name: 'X', descriptionMd: 'd', categoryId: 'cat-1', difficulty: 'MEDIUM', initialPoints: 100, minimumPoints: 50, decaySolves: 10, flag: 'f', protocol: 'WEB', port: null, ipAddress: '', enabled: true, ...overrides, }; } describe('pure: challenge form helpers', () => { it('defaultMinimumPoints returns half of the initial points, floored, with a minimum of 1', () => { expect(defaultMinimumPoints(100)).toBe(50); expect(defaultMinimumPoints(1)).toBe(1); expect(defaultMinimumPoints(3)).toBe(1); expect(pureDefault(99)).toBe(49); }); it('deriveMinimumPoints follows the auto-derived rule until the minimum is touched, then preserves the value', () => { expect(deriveMinimumPoints(200, 100, 100, false)).toBe(100); // touched: kept expect(deriveMinimumPoints(200, 50, 100, false)).toBe(100); // not touched, previous matched default expect(deriveMinimumPoints(50, 50, 100, false)).toBe(25); // auto-derives because new initial }); it('isValidIpOrHostname accepts IPv4, IPv6, hostnames and rejects garbage', () => { expect(isValidIpOrHostname('127.0.0.1')).toBe(true); expect(isValidIpOrHostname('::1')).toBe(true); expect(isValidIpOrHostname('2001:db8::1')).toBe(true); expect(isValidIpOrHostname('example.com')).toBe(true); expect(isValidIpOrHostname('')).toBe(true); expect(isValidIpOrHostname('not a host')).toBe(false); expect(isValidIpOrHostname('-bad.com')).toBe(false); }); it('validateChallengeForm flags missing name, min > initial, NC without port, invalid IP', () => { const errs = validateChallengeForm( makeDefaults({ name: '', descriptionMd: '', categoryId: '', flag: '', protocol: 'NC', port: null, initialPoints: 100, minimumPoints: 200, decaySolves: 0, ipAddress: 'bad value', }), ); expect(errs.name).toBeDefined(); expect(errs.descriptionMd).toBeDefined(); expect(errs.categoryId).toBeDefined(); expect(errs.minimumPoints).toBeDefined(); expect(errs.flag).toBeDefined(); expect(errs.port).toBeDefined(); expect(errs.decaySolves).toBeDefined(); expect(errs.ipAddress).toBeDefined(); }); it('validateChallengeForm accepts a well-formed WEB challenge with optional port', () => { const errs = validateChallengeForm(makeDefaults()); expect(errs).toEqual({}); }); it('mapServerFieldErrors flattens a Zod details array into per-field messages', () => { const errs = mapServerFieldErrors([ { path: 'name', message: 'taken' }, { path: 'port', message: 'required' }, ]); expect(errs.name).toBe('taken'); expect(errs.port).toBe('required'); }); it('prefillChallengeFormDefaults hydrates every field from the detail', () => { const d = makeDetail({ minimumPoints: 75, protocol: 'WEB', port: 443 }); const out = prefillChallengeFormDefaults(d); expect(out.name).toBe('Sample'); expect(out.minimumPoints).toBe(75); expect(out.protocol).toBe('WEB'); expect(out.port).toBe(443); }); it('toCreateBody omits WEB port when null and keeps NC port', () => { const web = toCreateBody(makeDefaults({ protocol: 'WEB', port: null }), []); expect(web.port).toBeNull(); const nc = toCreateBody(makeDefaults({ protocol: 'NC', port: 1337 }), []); expect(nc.port).toBe(1337); }); it('toCreateBody maps staged files into the manifest, including removal markers', () => { const files: ChallengeFormFileItem[] = [ { id: 'f1', originalFilename: '', mimeType: '', sizeBytes: 0, remove: true }, { stagedToken: 'tok', originalFilename: 'a.txt', mimeType: 'text/plain', sizeBytes: 5, }, ]; const out = toCreateBody(makeDefaults(), files); expect(out.files?.length).toBe(2); expect(out.files?.[0].remove).toBe(true); expect(out.files?.[1].stagedToken).toBe('tok'); }); it('buildInitialFiles hydrates a UI list from detail.files', () => { const list: ChallengeStagedFileUi[] = buildInitialFiles(makeDetail()); expect(list.length).toBe(1); expect(list[0].kind).toBe('existing'); expect(list[0].id).toBe('f1'); }); }); describe('syncChallengeForm', () => { it('populates every control from the detail and marks them pristine', () => { const form = buildChallengeFormGroup(new FormBuilder()); const out = syncChallengeForm(form, 'edit', makeDetail()); expect(form.controls.name.value).toBe('Sample'); expect(form.controls.initialPoints.value).toBe(200); expect(form.controls.minimumPoints.value).toBe(100); expect(form.controls.flag.value).toBe('flag{X}'); expect(form.controls.protocol.value).toBe('NC'); expect(form.controls.port.value).toBe(1337); expect(form.controls.ipAddress.value).toBe('127.0.0.1'); expect(form.controls.name.pristine).toBe(true); expect(out.minimumPoints).toBe(100); }); it('resets every control when switching to create mode with null detail', () => { const form = buildChallengeFormGroup(new FormBuilder()); syncChallengeForm(form, 'edit', makeDetail()); const out = syncChallengeForm(form, 'create', null); expect(form.controls.name.value).toBe(''); expect(form.controls.initialPoints.value).toBe(100); expect(form.controls.minimumPoints.value).toBe(50); expect(out.protocol).toBe('WEB'); }); });