diff --git a/api/src/lib/validation-rules.js b/api/src/lib/validation-rules.js index 8afad93b..c7ba4b52 100644 --- a/api/src/lib/validation-rules.js +++ b/api/src/lib/validation-rules.js @@ -121,12 +121,13 @@ function generateRules() { // FIXME: this is supposed to be the contents of templateRules.json const rules = srcRules + // TODO issues with EIN* not being in the rules? // subrecipient EIN is actually a length-10 string - rules.subrecipient.EIN__c.dataType = 'String' - rules.subrecipient.EIN__c.maxLength = 10 + // rules.subrecipient.EIN__c.dataType = 'String' + // rules.subrecipient.EIN__c.maxLength = 10 - rules.awards50k.Recipient_EIN__c.dataType = 'String' - rules.awards50k.Recipient_EIN__c.maxLength = 10 + // rules.awards50k.Recipient_EIN__c.dataType = 'String' + // rules.awards50k.Recipient_EIN__c.maxLength = 10 // value formatters modify the value in the record before it's validated // we check any rule against the formatted value diff --git a/api/src/lib/workbookValidation.test.ts b/api/src/lib/workbookValidation.test.ts index e111bf09..c1d0e247 100644 --- a/api/src/lib/workbookValidation.test.ts +++ b/api/src/lib/workbookValidation.test.ts @@ -1,12 +1,56 @@ +import fs from 'fs' + import { ValidationError } from 'src/lib/validation-error' +import { getRules } from 'src/lib/validation-rules' import { validateProjectUseCode, + validateRecord, validateVersion, + WorkbookRecord, } from 'src/lib/workbookValidation' -const rules = { - logic: { version: { version: 'v:20231212', columnName: 'B' } }, -} +const translateRecords = (records: WorkbookRecord[], type: string | number) => + records.filter((rec) => rec.type === type).map((r) => r.content) +const rules = getRules() +const realRecords = fs.readFileSync(`${__dirname}/cpf_uploadFile.json`, 'utf-8') +// console.log(workbook) +describe('the real example', () => { + let records + beforeEach(() => { + records = JSON.parse(realRecords) + }) + describe('validateVersion', () => { + it('should not return an error', () => { + expect(validateVersion({ rules, records })).toBeUndefined() + }) + }) + describe('validateProjectUseCode', () => { + it('should not return an error', () => { + expect(validateProjectUseCode({ records })).toBeUndefined() + }) + }) + describe('validateRecord', () => { + it('should return empty array for logic rules', () => { + expect( + validateRecord({ + upload: {}, + record: translateRecords(records, 'logic')[0], + typeRules: rules['logic'], + }) + ).toHaveLength(0) + }) + }) + // describe('validateRules', () => { + // it('should ', () => { + // expect( + // validateRules({ + // rules: JSON.parse(realRules), + // records: [JSON.parse(workbook)], + // }) + // ).toBe(undefined) + // }) + // }) +}) describe('workbookValidation tests', () => { let actualResult: unknown @@ -27,7 +71,10 @@ describe('workbookValidation tests', () => { }) describe('when "logic" record is missing content', () => { beforeEach(() => { - const records = [{ type: 'logic' }] + const records = [{ type: 'logic', upload: { id: 1 } }] + // Seems important enough to supply a specific error in case we don't have a "content" object + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore actualResult = validateVersion({ records, rules }) }) it('should return ValidationError', () => { @@ -38,7 +85,7 @@ describe('workbookValidation tests', () => { }) describe('when "logic" content is missing version', () => { beforeEach(() => { - const records = [{ type: 'logic', content: {} }] + const records = [{ type: 'logic', content: {}, upload: { id: 1 } }] actualResult = validateVersion({ records, rules }) }) it('should return undefined', () => { @@ -47,7 +94,13 @@ describe('workbookValidation tests', () => { }) describe('when version is older than the template', () => { beforeEach(() => { - const records = [{ type: 'logic', content: { version: 'v:20221212' } }] + const records = [ + { + type: 'logic', + content: { version: 'v:20221212' }, + upload: { id: 1 }, + }, + ] actualResult = validateVersion({ records, rules }) }) it('should return ValidationError', () => { @@ -60,7 +113,13 @@ describe('workbookValidation tests', () => { }) describe('when version is newer than the template', () => { beforeEach(() => { - const records = [{ type: 'logic', content: { version: 'v:20241212' } }] + const records = [ + { + type: 'logic', + content: { version: 'v:20241212' }, + upload: { id: 1 }, + }, + ] actualResult = validateVersion({ records, rules }) }) it('should return ValidationError', () => { @@ -73,7 +132,13 @@ describe('workbookValidation tests', () => { }) describe('when versions match', () => { beforeEach(() => { - const records = [{ type: 'logic', content: { version: 'v:20231212' } }] + const records = [ + { + type: 'logic', + content: { version: 'v:20231212' }, + upload: { id: 1 }, + }, + ] actualResult = validateVersion({ records, rules }) }) it('should return undefined', () => { @@ -152,4 +217,379 @@ describe('workbookValidation tests', () => { }) }) }) + describe('validateRecord', () => { + let typeRules, record, upload + afterEach(() => { + typeRules = undefined + record = undefined + upload = undefined + }) + describe('required content', () => { + beforeEach(() => { + upload = undefined + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + some: 'thing', + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + }) + describe('when a required key item is missing', () => { + it('should return ValidationError', () => { + expect(validateRecord({ upload, record, typeRules })).toEqual( + expect.arrayContaining([ + new ValidationError('Value is required for Project_Name__c'), + ]) + ) + }) + }) + describe('when an optional key item is missing', () => { + it('should not have error for missing item', () => { + expect(validateRecord({ upload, record, typeRules })).not.toEqual( + expect.arrayContaining([ + new ValidationError( + 'Value is required for Additional_Address__c' + ), + ]) + ) + }) + }) + }) + describe('listVals content', () => { + describe('when a listValue does not match', () => { + beforeEach(() => { + upload = undefined + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Matching_Funds__c: 'Hello World', + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + }) + it('should return a "one of" ValidationError', () => { + const errors = validateRecord({ upload, record, typeRules }) + expect(errors).toEqual( + expect.arrayContaining([ + new ValidationError( + "Value for Matching_Funds__c ('hello world') must be one of 2 options in the input template" + ), + ]) + ) + }) + }) + }) + describe('currency', () => { + describe('when the value does not match the currency type', () => { + it('should have error for invalid currency', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Total_from_all_funding_sources__c: 'hello world', + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + expect(validateRecord({ upload, record, typeRules })).toEqual( + expect.arrayContaining([ + new ValidationError( + 'Data entered in cell is "hello world", but it must be a number with at most 2 decimals' + ), + ]) + ) + }) + }) + describe('when the value is a valid currency', () => { + it('should not have error for invalid currency', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Total_from_all_funding_sources__c: 1234.98, + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + expect(validateRecord({ upload, record, typeRules })).not.toEqual( + expect.arrayContaining([ + new ValidationError( + 'Data entered in cell is "hello world", but it must be a number with at most 2 decimals' + ), + ]) + ) + }) + }) + }) + describe('date', () => { + describe('when the value does not match the date type', () => { + it('should have error for invalid Date', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Projected_Con_Start_Date__c: 'hello world', + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + expect(validateRecord({ upload, record, typeRules })).toEqual( + expect.arrayContaining([ + new ValidationError( + 'Data entered in cell is "hello world", which is not a valid date.' + ), + ]) + ) + }) + }) + describe('when the value is a valid Date', () => { + it('should not have error for invalid Date', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Projected_Con_Start_Date__c: 1478148420000, + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + expect(validateRecord({ upload, record, typeRules })).not.toEqual( + expect.arrayContaining([ + new ValidationError( + 'Data entered in cell is "1478148420000", which is not a valid date.' + ), + ]) + ) + }) + }) + }) + describe('String', () => { + describe('when the POC_Email_Address__c is not a valid email', () => { + it('should have the Email validation error', () => { + const translatedRecords = translateRecords( + [ + { + type: 'subrecipient', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + POC_Email_Address__c: 'Hello.World', + }, + }, + ], + 'subrecipient' + ) + record = translatedRecords[0] + typeRules = rules['subrecipient'] + const errors = validateRecord({ upload, record, typeRules }) + expect(errors).toEqual( + expect.arrayContaining([ + new ValidationError( + 'Value entered in cell is "Hello.World". Email must be of the form "user@email.com"' + ), + ]) + ) + }) + }) + describe('when the POC_Email_Address__c is a valid email', () => { + it('should not have the "email format" error', () => { + const translatedRecords = translateRecords( + [ + { + type: 'subrecipient', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + POC_Email_Address__c: 'foo@example.com', + }, + }, + ], + 'subrecipient' + ) + record = translatedRecords[0] + typeRules = rules['subrecipient'] + expect(validateRecord({ upload, record, typeRules })).not.toEqual( + expect.arrayContaining([ + new ValidationError( + 'Value entered in cell is "foo@example.com". Email must be of the form "user@email.com"' + ), + ]) + ) + }) + }) + }) + describe('Numeric', () => { + describe('when the Total_Miles_Planned__c is not a valid number', () => { + it('should have the numeric validation error', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Total_Miles_Planned__c: 'Hello.World', + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + const errors = validateRecord({ upload, record, typeRules }) + expect(errors).toEqual( + expect.arrayContaining([ + new ValidationError( + "Expected a number, but the value was 'Hello.World'" + ), + ]) + ) + }) + }) + describe('when the Total_Miles_Planned__c is a valid number', () => { + it('should not have the "Expected a number" error', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Total_Miles_Planned__c: 42, + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + expect(validateRecord({ upload, record, typeRules })).not.toEqual( + expect.arrayContaining([ + new ValidationError("Expected a number, but the value was '42'"), + ]) + ) + }) + }) + }) + describe('maxLength', () => { + describe('when the Zip_Code_Planned__c is greater than maxLength', () => { + it('should have the maxLength validation error', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Zip_Code_Planned__c: 'asdf_asdf_asdf', + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + const errors = validateRecord({ upload, record, typeRules }) + expect(errors).toEqual( + expect.arrayContaining([ + new ValidationError( + 'Value for Zip_Code_Planned__c cannot be longer than 5 (currently, 14)' + ), + ]) + ) + }) + }) + describe('when the Zip_Code_Planned__c is less than maxLength', () => { + it('should not have the "Expected a number" error', () => { + const translatedRecords = translateRecords( + [ + { + type: 'ec1', + subcategory: '1A-Broadband Infrastructure', + upload: { + id: 1, + }, + content: { + Zip_Code_Planned__c: 'asdf', + }, + }, + ], + 'ec1' + ) + record = translatedRecords[0] + typeRules = rules['ec1'] + expect(validateRecord({ upload, record, typeRules })).not.toEqual( + expect.arrayContaining([ + new ValidationError( + 'Value for Zip_Code_Planned__c cannot be longer than 5 (currently, 4)' + ), + ]) + ) + }) + }) + }) + }) }) diff --git a/api/src/lib/workbookValidation.ts b/api/src/lib/workbookValidation.ts index 62fbd402..ebf939fa 100644 --- a/api/src/lib/workbookValidation.ts +++ b/api/src/lib/workbookValidation.ts @@ -1,8 +1,61 @@ import projectUseCodes from 'src/lib/projectUseCodes' import { ValidationError } from 'src/lib/validation-error' +// Currency strings are must be at least one digit long (\d+) +// They can optionally have a decimal point followed by 1 or 2 more digits (?: \.\d{ 1, 2 }) +const CURRENCY_REGEX_PATTERN = /^\d+(?: \.\d{ 1, 2 })?$/g + +// Copied from www.emailregex.com +const EMAIL_REGEX_PATTERN = + /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +const BETA_VALIDATION_MESSAGE = + '[BETA] This is a new validation that is running in beta mode (as a warning instead of a blocking error). If you see anything incorrect about this validation, please report it at grants-helpdesk@usdigitalresponse.org' + +const SHOULD_NOT_CONTAIN_PERIOD_REGEX_PATTERN = /^[^.]*$/ + +// This maps from field name to regular expression that must match on the field. +// Note that this only covers cases where the name of the field is what we want to match on. +const FIELD_NAME_TO_PATTERN = { + POC_Email_Address__c: { + pattern: EMAIL_REGEX_PATTERN, + explanation: 'Email must be of the form "user@email.com"', + }, + Place_of_Performance_City__c: { + pattern: SHOULD_NOT_CONTAIN_PERIOD_REGEX_PATTERN, + explanation: 'Field must not contain a period.', + }, +} + +export type Rule = { + key: string + index: number + required: boolean | string + dataType: string + maxLength: number + listVals: string[] + columnName: string + humanColName: string + ecCodes: string[] | boolean + version?: string +} + +export interface TranslatedRule extends Rule { + isRequiredFn?: (value: Record) => boolean + validationFormatters: any[] +} + +export type WorkbookRecord = { + type: string + upload: { id: number } + content: Record + subcategory?: string +} + export function validateProjectUseCode({ records }) { - const coverRecord = records.find((record) => record?.type === 'cover') + const coverRecord = records.find( + (record: { type: string }) => record?.type === 'cover' + ) if (!coverRecord) { return new ValidationError(`Upload is missing Cover record`, { tab: 'cover', @@ -56,7 +109,7 @@ export function validateVersion({ records, rules, }: { - records: any[] + records: WorkbookRecord[] rules: { logic: { version: { version: string; columnName: string } } } }) { const logicRecord = records.find((record) => record?.type === 'logic') @@ -102,3 +155,187 @@ export function validateVersion({ return undefined } + +function validateFieldPattern(fieldName, value) { + let error = null + const matchedFieldPatternInfo = FIELD_NAME_TO_PATTERN[fieldName] + if (matchedFieldPatternInfo) { + const { pattern } = matchedFieldPatternInfo + const { explanation } = matchedFieldPatternInfo + if (value && typeof value === 'string' && !value.match(pattern)) { + error = new Error(`Value entered in cell is "${value}". ${explanation}`) + } + } + return error +} + +export function validateRecord({ + upload, + record, + typeRules, +}: { + upload: unknown + record: Record + typeRules: Record +}) { + const errors = [] + for (const [key, rule] of Object.entries(typeRules)) { + const recordItem = record[key] + if ( + [undefined, null].includes(recordItem) || + (typeof recordItem === 'string' && recordItem === '') + ) { + if (rule.required === true) { + errors.push( + new ValidationError(`Value is required for ${key}`, { + col: rule.columnName, + severity: 'err', + }) + ) + } else if (rule.required === 'Conditional') { + if (rule.isRequiredFn && rule.isRequiredFn(record)) { + errors.push( + new ValidationError( + // This message should make it clear that this field is conditionally required + `Based on other values in this row, a value is required for ${key}`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + } + } else { + let value = recordItem + let formatFailures = 0 + for (const formatter of rule.validationFormatters) { + try { + value = formatter(value) + } catch (e) { + formatFailures += 1 + } + } + if (formatFailures) { + errors.push( + new ValidationError( + `Failed to apply ${formatFailures} formatters while validating value`, + { col: rule.columnName, severity: 'warn' } + ) + ) + } + + if (rule.listVals.length > 0) { + // enforce validation in lower case + const lcItems = rule.listVals.map((val) => val.toLowerCase()) + + // for pick lists, the value must be one of possible values + if ( + rule.dataType === 'Pick List' && + !lcItems.includes(value.toString()) + ) { + errors.push( + new ValidationError( + `Value for ${key} ('${value}') must be one of ${lcItems.length} options in the input template`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + + // for multi select, all the values must be in the list of possible values + if (rule.dataType === 'Multi-Select') { + const entries = value + .toString() + .split(';') + .map((val) => val.trim()) + for (const entry of entries) { + if (!lcItems.includes(entry)) { + errors.push( + new ValidationError( + `Entry '${entry}' of ${key} is not one of ${lcItems.length} valid options`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + } + } + } + + if ( + ['Currency', 'Currency1', 'Currency2', 'Currency3'].includes( + rule.dataType + ) + ) { + if ( + value && + typeof value === 'string' && + !value.match(CURRENCY_REGEX_PATTERN) + ) { + errors.push( + new ValidationError( + `Data entered in cell is "${value}", but it must be a number with at most 2 decimals`, + { severity: 'err', col: rule.columnName } + ) + ) + } + } + + if (rule.dataType === 'Date') { + if ( + value && + typeof value === 'string' && + Number.isNaN(Date.parse(value)) + ) { + errors.push( + new ValidationError( + `Data entered in cell is "${value}", which is not a valid date.`, + { severity: 'err', col: rule.columnName } + ) + ) + } + } + + if (rule.dataType === 'String') { + const patternError = validateFieldPattern(key, value) + if (patternError) { + errors.push( + new ValidationError(patternError.message, { + severity: 'err', + col: rule.columnName, + }) + ) + } + } + + if (rule.dataType === 'Numeric') { + if (typeof value === 'string' && Number.isNaN(parseFloat(value))) { + // If this value is a string that can't be interpreted as a number, then error. + // Note: This value might not be exactly what was entered in the workbook. The val + // has already been fed through formatters that may have changed the value. + errors.push( + new ValidationError( + `Expected a number, but the value was '${value}'`, + { severity: 'err', col: rule.columnName } + ) + ) + } + } + + // make sure max length is not too long + if (rule.maxLength) { + if ( + (rule.dataType === 'String' || rule.dataType === 'String-Fixed') && + String(record[key]).length > rule.maxLength + ) { + errors.push( + new ValidationError( + `Value for ${key} cannot be longer than ${ + rule.maxLength + } (currently, ${String(record[key]).length})`, + { col: rule.columnName, severity: 'err' } + ) + ) + } + // TODO: should we validate max length on currency? or numeric fields? + } + } + } + return errors +}