From 1ae55591f4e46bc82551e1eff4b3a1237b7f43f7 Mon Sep 17 00:00:00 2001 From: Experiments DB Dev Date: Fri, 1 May 2026 07:47:17 -0400 Subject: [PATCH] feat(backend): add csv_analysis_config validator --- backend/src/lib/csvConfigValidation.js | 51 +++++++++++++++++++ backend/tests/csvConfigValidation.test.js | 61 +++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 backend/src/lib/csvConfigValidation.js create mode 100644 backend/tests/csvConfigValidation.test.js diff --git a/backend/src/lib/csvConfigValidation.js b/backend/src/lib/csvConfigValidation.js new file mode 100644 index 0000000..23095dc --- /dev/null +++ b/backend/src/lib/csvConfigValidation.js @@ -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 }; diff --git a/backend/tests/csvConfigValidation.test.js b/backend/tests/csvConfigValidation.test.js new file mode 100644 index 0000000..9b94053 --- /dev/null +++ b/backend/tests/csvConfigValidation.test.js @@ -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/); + }); +});