AI Implementation feature(861): Admin Area Challenges: List, Import/Export and Add/Edit Modal (#40)
This commit was merged in pull request #40.
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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> = {}): 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> = {}): 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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
AdminChallengeListItem,
|
||||
ImportPreviewResponse,
|
||||
ImportResultResponse,
|
||||
} from '../../frontend/src/app/core/services/admin.service';
|
||||
import {
|
||||
buildExportFilename,
|
||||
categoryIconSrc,
|
||||
deriveEmptyState,
|
||||
difficultyClass,
|
||||
formatFileSize,
|
||||
summarizeImportPreview,
|
||||
summarizeImportResult,
|
||||
} from '../../frontend/src/app/features/admin/challenges/challenges.pure';
|
||||
|
||||
function makeRow(overrides: Partial<AdminChallengeListItem> = {}): AdminChallengeListItem {
|
||||
return {
|
||||
id: 'c1',
|
||||
name: 'Alpha',
|
||||
categoryAbbreviation: 'CRY',
|
||||
categoryIconPath: '/uploads/icons/CRY.png',
|
||||
difficulty: 'LOW',
|
||||
protocol: 'WEB',
|
||||
enabled: true,
|
||||
createdAt: '2026-07-22T00:00:00.000Z',
|
||||
updatedAt: '2026-07-22T00:00:00.000Z',
|
||||
fileCount: 0,
|
||||
solveCount: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('challenges.pure helpers', () => {
|
||||
it('formatFileSize formats bytes / KB / MB', () => {
|
||||
expect(formatFileSize(0)).toBe('0 B');
|
||||
expect(formatFileSize(512)).toBe('512 B');
|
||||
expect(formatFileSize(2048)).toMatch(/KB$/);
|
||||
expect(formatFileSize(5 * 1024 * 1024)).toMatch(/MB$/);
|
||||
});
|
||||
|
||||
it('buildExportFilename derives an ISO-date filename', () => {
|
||||
const fixed = new Date('2026-07-22T12:00:00.000Z');
|
||||
expect(buildExportFilename(fixed)).toBe('challenges-export-2026-07-22.json');
|
||||
});
|
||||
|
||||
it('deriveEmptyState distinguishes empty-list, no-results-search, and loaded states', () => {
|
||||
expect(deriveEmptyState([], '', false)).toEqual({ kind: 'empty-list' });
|
||||
expect(deriveEmptyState([], 'crypto', false)).toEqual({
|
||||
kind: 'no-results-search',
|
||||
search: 'crypto',
|
||||
});
|
||||
expect(deriveEmptyState([makeRow()], '', false)).toBeNull();
|
||||
expect(deriveEmptyState([], '', true)).toBeNull();
|
||||
});
|
||||
|
||||
it('summarizeImportPreview builds a single-line preview summary', () => {
|
||||
const preview: ImportPreviewResponse = {
|
||||
total: 3,
|
||||
conflicts: ['Alpha', 'Beta'],
|
||||
unknownCategories: ['UNKNOWN'],
|
||||
warnings: [],
|
||||
};
|
||||
const out = summarizeImportPreview(preview);
|
||||
expect(out).toContain('Import 3 challenges');
|
||||
expect(out).toContain('overwritten');
|
||||
expect(out).toContain('missing categories');
|
||||
});
|
||||
|
||||
it('summarizeImportPreview handles empty document', () => {
|
||||
const preview: ImportPreviewResponse = {
|
||||
total: 0,
|
||||
conflicts: [],
|
||||
unknownCategories: [],
|
||||
warnings: [],
|
||||
};
|
||||
expect(summarizeImportPreview(preview)).toContain('no challenges');
|
||||
});
|
||||
|
||||
it('summarizeImportResult reports imported/skipped/warning counts', () => {
|
||||
const r: ImportResultResponse = {
|
||||
imported: 2,
|
||||
skipped: [{ name: 'X', reason: 'Unknown category: Y' }],
|
||||
warnings: ['1 skipped.'],
|
||||
};
|
||||
const out = summarizeImportResult(r);
|
||||
expect(out).toContain('Imported 2');
|
||||
expect(out).toContain('Skipped 1');
|
||||
expect(out).toContain('1 warning');
|
||||
});
|
||||
|
||||
it('difficultyClass returns the appropriate badge class', () => {
|
||||
expect(difficultyClass('LOW')).toContain('difficulty-low');
|
||||
expect(difficultyClass('MEDIUM')).toContain('difficulty-medium');
|
||||
expect(difficultyClass('HIGH')).toContain('difficulty-high');
|
||||
});
|
||||
|
||||
it('categoryIconSrc returns the supplied icon path or an empty string', () => {
|
||||
expect(categoryIconSrc('/uploads/icons/X.png', 'X')).toBe('/uploads/icons/X.png');
|
||||
expect(categoryIconSrc('', 'X')).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user