feat(backend): add csv_analysis_config validator

This commit is contained in:
Experiments DB Dev
2026-05-01 07:47:17 -04:00
parent 7b023fb8a3
commit 1ae55591f4
2 changed files with 112 additions and 0 deletions
+51
View File
@@ -0,0 +1,51 @@
const REQUIRED_DEFAULT_BUCKETS = ['Success', 'Failure', 'Other'];
const ALLOWED_TYPES = new Set(['note', 'numerical']);
/**
* Validate a csv_analysis_config payload.
* @param {unknown} body
* @returns {string|null} error message, or null if valid.
*/
function validateCsvConfig(body) {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return 'Body must be an object';
}
const { buckets } = body;
if (!Array.isArray(buckets)) {
return 'buckets must be an array';
}
const seenNames = new Set();
for (const b of buckets) {
if (b === null || typeof b !== 'object' || Array.isArray(b)) {
return 'Each bucket must be an object';
}
if (typeof b.name !== 'string' || b.name.trim() === '') {
return 'Each bucket must have a non-empty name';
}
if (seenNames.has(b.name)) {
return `Duplicate bucket name: ${b.name}`;
}
seenNames.add(b.name);
if (!ALLOWED_TYPES.has(b.type)) {
return `Invalid type for bucket ${b.name}: ${b.type}`;
}
if (!Array.isArray(b.notes)) {
return `notes must be an array for bucket ${b.name}`;
}
if (!b.notes.every((n) => typeof n === 'string')) {
return `notes must be an array of strings for bucket ${b.name}`;
}
}
for (const required of REQUIRED_DEFAULT_BUCKETS) {
if (!seenNames.has(required)) {
return `Missing required default bucket: ${required}`;
}
}
return null;
}
module.exports = { validateCsvConfig, REQUIRED_DEFAULT_BUCKETS };
+61
View File
@@ -0,0 +1,61 @@
const { validateCsvConfig } = require('../src/lib/csvConfigValidation');
const okBuckets = [
{ name: 'Success', type: 'note', notes: ['s'] },
{ name: 'Failure', type: 'note', notes: ['f'] },
{ name: 'Other', type: 'note', notes: [] },
];
describe('validateCsvConfig', () => {
it('accepts a valid config with the three default buckets', () => {
expect(validateCsvConfig({ buckets: okBuckets })).toBeNull();
});
it('accepts a valid config with extra note + numerical buckets', () => {
const buckets = [
...okBuckets,
{ name: 'Lick latency', type: 'numerical', notes: ['L25'] },
{ name: 'Bonus', type: 'note', notes: ['b'] },
];
expect(validateCsvConfig({ buckets })).toBeNull();
});
it('rejects when body is not an object', () => {
expect(validateCsvConfig(null)).toMatch(/object/i);
expect(validateCsvConfig('x')).toMatch(/object/i);
});
it('rejects when buckets is not an array', () => {
expect(validateCsvConfig({ buckets: 'nope' })).toMatch(/array/i);
});
it('rejects an empty bucket name', () => {
const buckets = [...okBuckets, { name: ' ', type: 'note', notes: [] }];
expect(validateCsvConfig({ buckets })).toMatch(/name/i);
});
it('rejects duplicate bucket names', () => {
const buckets = [...okBuckets, { name: 'Success', type: 'note', notes: [] }];
expect(validateCsvConfig({ buckets })).toMatch(/duplicate/i);
});
it('rejects an invalid type value', () => {
const buckets = [...okBuckets, { name: 'X', type: 'weird', notes: [] }];
expect(validateCsvConfig({ buckets })).toMatch(/type/i);
});
it('rejects when notes is not an array', () => {
const buckets = [...okBuckets, { name: 'X', type: 'note', notes: 'oops' }];
expect(validateCsvConfig({ buckets })).toMatch(/notes/i);
});
it('rejects when notes contains non-strings', () => {
const buckets = [...okBuckets, { name: 'X', type: 'note', notes: [1, 2] }];
expect(validateCsvConfig({ buckets })).toMatch(/notes/i);
});
it('rejects when a default bucket is missing', () => {
const buckets = okBuckets.filter((b) => b.name !== 'Success');
expect(validateCsvConfig({ buckets })).toMatch(/Success/);
});
});