diff --git a/src/data/fixtures/common/epr-organisations/sample-organisation-1.json b/src/data/fixtures/common/epr-organisations/sample-organisation-1.json index 5b2e2067..7ed5ad6a 100644 --- a/src/data/fixtures/common/epr-organisations/sample-organisation-1.json +++ b/src/data/fixtures/common/epr-organisations/sample-organisation-1.json @@ -36,6 +36,9 @@ "registrations": [ { "id": "6507f1f77bcf86cd79943902", + "registrationNumber": null, + "validFrom": null, + "validTo": null, "statusHistory": [ { "status": "created", @@ -137,6 +140,9 @@ }, { "id": "68f6a147c117aec8a1ab7494", + "registrationNumber": null, + "validFrom": null, + "validTo": null, "statusHistory": [ { "status": "created", @@ -187,6 +193,9 @@ "accreditations": [ { "id": "68f6a147c117aec8a1ab7495", + "accreditationNumber": null, + "validFrom": null, + "validTo": null, "formSubmissionTime": "2025-08-20T21:34:44.944Z", "statusHistory": [ { @@ -235,6 +244,9 @@ }, { "id": "68f6a147c117aec8a1ab7496", + "accreditationNumber": null, + "validFrom": null, + "validTo": null, "formSubmissionTime": "2025-08-21T21:34:44.944Z", "statusHistory": [ { @@ -283,6 +295,9 @@ }, { "id": "68f6a147c117aec8a1ab7494", + "accreditationNumber": null, + "validFrom": null, + "validTo": null, "formSubmissionTime": "2025-08-22T21:34:44.944Z", "statusHistory": [ { diff --git a/src/data/fixtures/common/epr-organisations/sample-organisation-2.json b/src/data/fixtures/common/epr-organisations/sample-organisation-2.json index 27f40bcc..713a697e 100644 --- a/src/data/fixtures/common/epr-organisations/sample-organisation-2.json +++ b/src/data/fixtures/common/epr-organisations/sample-organisation-2.json @@ -1,6 +1,6 @@ { "id": "6507f1f77bcf86cd79943921", - "orgId": 50004, + "orgId": 500004, "schemaVersion": 1, "version": 1, "statusHistory": [ @@ -23,6 +23,10 @@ "registrations": [ { "id": "6507f1f77bcf86cd79943922", + "registrationNumber": "R26ER2375628628-PL", + "validFrom": "2026-01-01T00:00:00.000Z", + "validTo": "2027-01-01T00:00:00.000Z", + "accreditationId": "68f6a147c117aec8a1ab7499", "statusHistory": [ { "status": "created", @@ -35,25 +39,18 @@ "material": "plastic", "wasteProcessingType": "exporter", "cbduNumber": "CBDU789012", - "wasteManagementPermits": [ - { - "type": "environmental_permit", - "permitNumber": "WML789012", - "authorisedMaterials": [ - { - "material": "plastic", - "authorisedWeightInTonnes": 30, - "timeScale": "yearly" - } - ] - } - ], "approvedPersons": [ { - "fullName": "Carol White", - "email": "carol.white@export.com", - "title": "Owner", - "phone": "2223334444" + "fullName": "Peter Plum", + "email": "peter.plum@export.com", + "title": "Manager", + "phone": "0987654321" + }, + { + "fullName": "Yoda", + "email": "yoda@starwars.com", + "title": "PRN signatory", + "phone": "1234567890" } ], "suppliers": "International plastic suppliers", @@ -64,7 +61,13 @@ "title": "Owner", "phone": "2223334444" }, - "samplingInspectionPlanFileUploads": [ + "samplingInspectionPlanPart1FileUploads": [ + { + "defraFormUploadedFileId": "file-789", + "defraFormUserDownloadLink": "https://example.com/file-789" + } + ], + "orsFileUploads": [ { "defraFormUploadedFileId": "file-789", "defraFormUserDownloadLink": "https://example.com/file-789" @@ -80,24 +83,25 @@ "accreditations": [ { "id": "68f6a147c117aec8a1ab7499", + "accreditationNumber": "A26ER2375628628-PL", + "validFrom": "2026-01-01T00:00:00.000Z", + "validTo": "2027-01-01T00:00:00.000Z", "formSubmissionTime": "2025-10-01T09:00:00.000Z", "statusHistory": [ { "status": "created", - "updatedAt": "2025-10-01T09:00:00.000Z" + "updatedAt": "2025-10-01T08:30:00.000Z" + }, + { + "status": "approved", + "updatedAt": "2025-11-01T08:30:00.000Z" } ], "submittedToRegulator": "nrw", "orgName": "Plastic Exporters", - "site": { - "address": { - "line1": "22 Export Lane", - "postcode": "CF10 1AA" - } - }, "material": "plastic", "wasteProcessingType": "exporter", - "pernIssuance": { + "prnIssuance": { "tonnageBand": "up_to_5000", "signatories": [ { @@ -107,7 +111,7 @@ "phone": "5556667777" } ], - "pernIncomeBusinessPlan": [ + "incomeBusinessPlan": [ { "usageDescription": "Expand export fleet", "detailedExplanation": "Purchase new trucks", @@ -119,6 +123,24 @@ "line1": "22 Export Lane", "town": "Cardiff", "postcode": "CF10 1AA" + }, + "samplingInspectionPlanPart2FileUploads": [ + { + "defraFormUploadedFileId": "file-789", + "defraFormUserDownloadLink": "https://example.com/file-789" + } + ], + "orsFileUploads": [ + { + "defraFormUploadedFileId": "file-789", + "defraFormUserDownloadLink": "https://example.com/file-789" + } + ], + "submitterContactDetails": { + "fullName": "Colin Mustard", + "email": "colin.mustard@export.com", + "title": "Manager", + "phone": "0987654321" } } ], diff --git a/src/data/fixtures/common/epr-organisations/sample-organisation-3.json b/src/data/fixtures/common/epr-organisations/sample-organisation-3.json index cb125f93..87be3739 100644 --- a/src/data/fixtures/common/epr-organisations/sample-organisation-3.json +++ b/src/data/fixtures/common/epr-organisations/sample-organisation-3.json @@ -23,6 +23,9 @@ "registrations": [ { "id": "6507f1f77bcf86cd79943932", + "registrationNumber": null, + "validFrom": null, + "validTo": null, "statusHistory": [ { "status": "created", @@ -123,6 +126,9 @@ }, { "id": "6507f1f77bcf86cd79943933", + "registrationNumber": null, + "validFrom": null, + "validTo": null, "statusHistory": [ { "status": "created", @@ -173,6 +179,9 @@ "accreditations": [ { "id": "68f6a147c117aec8a1ab749a", + "accreditationNumber": null, + "validFrom": null, + "validTo": null, "formSubmissionTime": "2025-11-01T13:00:00.000Z", "statusHistory": [ { diff --git a/src/data/fixtures/common/epr-organisations/sample-organisation-4.json b/src/data/fixtures/common/epr-organisations/sample-organisation-4.json index 75b02c67..5125e758 100644 --- a/src/data/fixtures/common/epr-organisations/sample-organisation-4.json +++ b/src/data/fixtures/common/epr-organisations/sample-organisation-4.json @@ -1,6 +1,6 @@ { "id": "6507f1f77bcf86cd79943911", - "orgId": 50003, + "orgId": 500003, "schemaVersion": 1, "version": 1, "statusHistory": [ @@ -23,6 +23,9 @@ "registrations": [ { "id": "6507f1f77bcf86cd79943912", + "registrationNumber": null, + "validFrom": null, + "validTo": null, "statusHistory": [ { "status": "created", @@ -123,6 +126,9 @@ }, { "id": "6507f1f77bcf86cd79943913", + "registrationNumber": null, + "validFrom": null, + "validTo": null, "statusHistory": [ { "status": "created", @@ -173,6 +179,9 @@ "accreditations": [ { "id": "68f6a147c117aec8a1ab7497", + "accreditationNumber": null, + "validFrom": null, + "validTo": null, "formSubmissionTime": "2025-09-09T11:00:00.000Z", "statusHistory": [ { @@ -221,6 +230,9 @@ }, { "id": "68f6a147c117aec8a1ab7498", + "accreditationNumber": null, + "validFrom": null, + "validTo": null, "formSubmissionTime": "2025-09-09T12:00:00.000Z", "statusHistory": [ { diff --git a/src/repositories/organisations/schema.js b/src/repositories/organisations/schema.js index 0cb474bf..d56d61be 100644 --- a/src/repositories/organisations/schema.js +++ b/src/repositories/organisations/schema.js @@ -80,7 +80,7 @@ const requiredWhenApprovedOrSuspended = { { is: STATUS.APPROVED, then: Joi.required() }, { is: STATUS.SUSPENDED, then: Joi.required() } ], - otherwise: Joi.optional() + otherwise: Joi.allow(null).optional() } export const idSchema = Joi.string() diff --git a/src/repositories/organisations/schema.test.js b/src/repositories/organisations/schema.test.js new file mode 100644 index 00000000..b84f457e --- /dev/null +++ b/src/repositories/organisations/schema.test.js @@ -0,0 +1,388 @@ +import { describe, expect, it } from 'vitest' +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { + organisationInsertSchema, + registrationSchema, + statusHistoryItemSchema +} from './schema.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) + +const SEED_DATA_DIR = join( + __dirname, + '../../data/fixtures/common/epr-organisations' +) + +/** + * Load JSON seed data file + */ +const loadSeedData = (filename) => { + const filePath = join(SEED_DATA_DIR, filename) + const content = readFileSync(filePath, 'utf-8') + return JSON.parse(content) +} + +/** + * Format Joi validation error details for readable test output + */ +const formatValidationError = (error) => { + return error.details + .map((d) => ` - ${d.path.join('.')}: ${d.message}`) + .join('\n') +} + +/** + * Validate organisation data using Joi schema + */ +const validateOrganisation = (data, filename) => { + const { error, value } = organisationInsertSchema.validate(data, { + abortEarly: false, + stripUnknown: false, + allowUnknown: true // Allow fields like schemaVersion, version that are in the seed data + }) + + if (error) { + const details = formatValidationError(error) + throw new Error( + `Validation failed for ${filename}:\n${details}\n\nFull error: ${error.message}` + ) + } + + return value +} + +/** + * Validate registration data using Joi schema + */ +const validateRegistrationData = (registration, filename, index) => { + const { error, value } = registrationSchema.validate(registration, { + abortEarly: false, + stripUnknown: false, + allowUnknown: true + }) + + if (error) { + const details = formatValidationError(error) + throw new Error( + `Registration validation failed for ${filename} (registration #${index}):\n${details}` + ) + } + + return value +} + +/** + * Validate status history items + */ +const validateStatusHistoryData = (statusHistory, filename, context) => { + statusHistory.forEach((item, index) => { + const { error } = statusHistoryItemSchema.validate(item, { + abortEarly: false + }) + + if (error) { + const details = formatValidationError(error) + throw new Error( + `Status history validation failed for ${filename} (${context}, item #${index}):\n${details}` + ) + } + }) +} + +describe('Seed Data Validation', () => { + const seedFiles = [ + 'sample-organisation-1.json', + 'sample-organisation-2.json', + 'sample-organisation-3.json', + 'sample-organisation-4.json' + ] + + describe('Organisation Schema Validation', () => { + seedFiles.forEach((filename) => { + it(`should validate ${filename} against organisationInsertSchema`, () => { + const data = loadSeedData(filename) + + expect(() => validateOrganisation(data, filename)).not.toThrow() + + // Additional validation checks + expect(data).toHaveProperty('id') + expect(data).toHaveProperty('orgId') + expect(data).toHaveProperty('wasteProcessingTypes') + expect(data).toHaveProperty('companyDetails') + expect(data).toHaveProperty('submitterContactDetails') + }) + }) + }) + + describe('Registration Schema Validation', () => { + seedFiles.forEach((filename) => { + it(`should validate all registrations in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.registrations && data.registrations.length > 0) { + data.registrations.forEach((registration, index) => { + expect(() => + validateRegistrationData(registration, filename, index) + ).not.toThrow() + + // Additional validation checks + expect(registration).toHaveProperty('id') + expect(registration).toHaveProperty('material') + expect(registration).toHaveProperty('wasteProcessingType') + expect(registration).toHaveProperty('cbduNumber') + }) + } + }) + }) + }) + + describe('Status History Validation', () => { + seedFiles.forEach((filename) => { + it(`should validate organisation status history in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.statusHistory) { + expect(() => + validateStatusHistoryData( + data.statusHistory, + filename, + 'organisation' + ) + ).not.toThrow() + } + }) + + it(`should validate registration status histories in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.registrations) { + data.registrations.forEach((registration, index) => { + if (registration.statusHistory) { + expect(() => + validateStatusHistoryData( + registration.statusHistory, + filename, + `registration #${index}` + ) + ).not.toThrow() + } + }) + } + }) + + it(`should validate accreditation status histories in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.accreditations) { + data.accreditations.forEach((accreditation, index) => { + if (accreditation.statusHistory) { + expect(() => + validateStatusHistoryData( + accreditation.statusHistory, + filename, + `accreditation #${index}` + ) + ).not.toThrow() + } + }) + } + }) + }) + }) + + describe('Data Integrity Checks', () => { + seedFiles.forEach((filename) => { + it(`should have valid waste processing types in ${filename}`, () => { + const data = loadSeedData(filename) + + expect(data.wasteProcessingTypes).toBeDefined() + expect(Array.isArray(data.wasteProcessingTypes)).toBe(true) + expect(data.wasteProcessingTypes.length).toBeGreaterThan(0) + + data.wasteProcessingTypes.forEach((type) => { + expect(['reprocessor', 'exporter']).toContain(type) + }) + }) + + it(`should have valid regulator in ${filename}`, () => { + const data = loadSeedData(filename) + + expect(data.submittedToRegulator).toBeDefined() + expect(['ea', 'nrw', 'sepa', 'niea']).toContain( + data.submittedToRegulator + ) + }) + + it(`should have valid contact details in ${filename}`, () => { + const data = loadSeedData(filename) + + expect(data.submitterContactDetails).toBeDefined() + expect(data.submitterContactDetails.fullName).toBeTruthy() + expect(data.submitterContactDetails.email).toMatch(/@/) + expect(data.submitterContactDetails.phone).toBeTruthy() + }) + + it(`should have valid company details in ${filename}`, () => { + const data = loadSeedData(filename) + + expect(data.companyDetails).toBeDefined() + expect(data.companyDetails.name).toBeTruthy() + + if (data.companyDetails.registrationNumber) { + expect(data.companyDetails.registrationNumber).toMatch( + /^[A-Z0-9]{8}$/i + ) + } + }) + }) + }) + + describe('Registration-specific Validation', () => { + seedFiles.forEach((filename) => { + it(`should validate reprocessor-specific fields in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.registrations) { + data.registrations + .filter((r) => r.wasteProcessingType === 'reprocessor') + .forEach((registration, index) => { + expect( + registration.site, + `Registration #${index} should have site for reprocessor` + ).toBeDefined() + expect( + registration.wasteManagementPermits, + `Registration #${index} should have wasteManagementPermits for reprocessor` + ).toBeDefined() + expect( + registration.plantEquipmentDetails, + `Registration #${index} should have plantEquipmentDetails for reprocessor` + ).toBeDefined() + expect( + registration.yearlyMetrics, + `Registration #${index} should have yearlyMetrics for reprocessor` + ).toBeDefined() + }) + } + }) + + it(`should validate exporter-specific fields in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.registrations) { + data.registrations + .filter((r) => r.wasteProcessingType === 'exporter') + .forEach((registration, index) => { + expect( + registration.exportPorts, + `Registration #${index} should have exportPorts for exporter` + ).toBeDefined() + expect( + registration.noticeAddress, + `Registration #${index} should have noticeAddress for exporter` + ).toBeDefined() + expect( + registration.orsFileUploads, + `Registration #${index} should have orsFileUploads for exporter` + ).toBeDefined() + }) + } + }) + + it(`should validate glass-specific fields in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.registrations) { + data.registrations + .filter((r) => r.material === 'glass') + .forEach((registration, index) => { + expect( + registration.glassRecyclingProcess, + `Registration #${index} should have glassRecyclingProcess for glass material` + ).toBeDefined() + expect(Array.isArray(registration.glassRecyclingProcess)).toBe( + true + ) + }) + } + }) + }) + }) + + describe('CBDU Number Validation', () => { + seedFiles.forEach((filename) => { + it(`should have valid CBDU numbers in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.registrations) { + data.registrations.forEach((registration, index) => { + expect( + registration.cbduNumber, + `Registration #${index} should have cbduNumber` + ).toBeDefined() + expect( + registration.cbduNumber, + `Registration #${index} CBDU number should start with CBDU` + ).toMatch(/^[cC][bB][dD][uU]/) + expect( + registration.cbduNumber.length, + `Registration #${index} CBDU number should be 8-10 characters` + ).toBeGreaterThanOrEqual(8) + expect( + registration.cbduNumber.length, + `Registration #${index} CBDU number should be 8-10 characters` + ).toBeLessThanOrEqual(10) + }) + } + }) + }) + }) + + describe('File Upload Validation', () => { + seedFiles.forEach((filename) => { + it(`should have valid file uploads in ${filename}`, () => { + const data = loadSeedData(filename) + + if (data.registrations) { + data.registrations.forEach((registration, index) => { + if (registration.samplingInspectionPlanPart1FileUploads) { + registration.samplingInspectionPlanPart1FileUploads.forEach( + (upload) => { + expect(upload.defraFormUploadedFileId).toBeTruthy() + expect(upload.defraFormUserDownloadLink).toMatch( + /^https?:\/\// + ) + } + ) + } + + if (registration.orsFileUploads) { + registration.orsFileUploads.forEach((upload) => { + expect(upload.defraFormUploadedFileId).toBeTruthy() + expect(upload.defraFormUserDownloadLink).toMatch(/^https?:\/\//) + }) + } + }) + } + + if (data.accreditations) { + data.accreditations.forEach((accreditation, index) => { + if (accreditation.samplingInspectionPlanPart2FileUploads) { + accreditation.samplingInspectionPlanPart2FileUploads.forEach( + (upload) => { + expect(upload.defraFormUploadedFileId).toBeTruthy() + expect(upload.defraFormUserDownloadLink).toMatch( + /^https?:\/\// + ) + } + ) + } + }) + } + }) + }) + }) +})