Add occurrence edit payload validation rules

This commit is contained in:
Kari Lintulaakso
2026-02-27 17:16:56 +02:00
parent dd2ed0522d
commit 80c6da0c14
7 changed files with 244 additions and 9 deletions
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from '@jest/globals'
import { pool } from '../../utils/db'
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'
describe('Occurrence update endpoint', () => {
beforeAll(async () => {
await resetDatabase()
}, resetDatabaseTimeout)
beforeEach(async () => {
await login()
})
afterAll(async () => {
await pool.end()
})
it('updates an occurrence by composite key', async () => {
const response = await send<Record<string, unknown>>('occurrence/20920/21052', 'PUT', {
occurrence: {
qua: 'a',
mesowear: 'bil',
microwear: 'pit_dom',
mw_scale_min: 0,
mw_scale_max: 5,
mw_value: 3,
},
})
expect(response.status).toBe(200)
expect(response.body.qua).toBe('a')
expect(response.body.mesowear).toBe('bil')
})
it('returns 400 for invalid dropdown values', async () => {
const response = await send<Record<string, unknown>>('occurrence/20920/21052', 'PUT', {
occurrence: {
qua: 'invalid',
mesowear: 'invalid',
},
})
expect(response.status).toBe(400)
expect(String(response.body.message)).toContain('Quantity')
expect(String(response.body.message)).toContain('Mesowear')
})
it('returns 400 for inconsistent mesowear scale values', async () => {
const response = await send<Record<string, unknown>>('occurrence/20920/21052', 'PUT', {
occurrence: {
mw_scale_min: 10,
mw_scale_max: 5,
mw_value: 12,
},
})
expect(response.status).toBe(400)
expect(String(response.body.message)).toContain('Scale Minimum cannot be greater than Scale Maximum')
})
})
@@ -1,7 +1,8 @@
import { Request, Response } from 'express'
import { getOccurrenceByCompositeKey, parseOccurrenceRouteParams } from '../services/occurrenceService'
import { fixBigInt } from '../utils/common'
import { EditableOccurrenceData, updateOccurrenceByCompositeKey } from '../services/write/occurrence'
import { EditableOccurrenceData } from '../../../frontend/src/shared/types'
import { updateOccurrenceByCompositeKey } from '../services/write/occurrence'
type OccurrenceRouteParams = {
lid: string
@@ -25,7 +26,8 @@ export const updateOccurrenceDetail = async (
) => {
const { lid, speciesId } = parseOccurrenceRouteParams(req.params.lid, req.params.speciesId)
const updatedOccurrence = await updateOccurrenceByCompositeKey(lid, speciesId, req.body.occurrence ?? {}, req.user)
const occurrencePayload: EditableOccurrenceData = req.body.occurrence ?? {}
const updatedOccurrence = await updateOccurrenceByCompositeKey(lid, speciesId, occurrencePayload, req.user)
if (!updatedOccurrence) {
return res.status(404).json({ message: 'Occurrence not found' })
+15 -1
View File
@@ -1,4 +1,5 @@
import { AnyReference, Role, User } from '../../../frontend/src/shared/types'
import { AnyReference, EditableOccurrenceData, Role, User } from '../../../frontend/src/shared/types'
import { validateOccurrence } from '../../../frontend/src/shared/validators/occurrence'
import { AccessError } from '../middlewares/authorizer'
import { logDb, nowDb } from '../utils/db'
import { buildPersonLookupByInitials, getPersonDisplayName, getPersonFromLookup } from './utils/person'
@@ -45,6 +46,19 @@ export const ensureOccurrenceEditAccess = async (lid: number, user: User) => {
throw new AccessError()
}
export const validateOccurrencePayload = (payload: EditableOccurrenceData) => {
const validationErrors = (Object.keys(payload) as Array<keyof EditableOccurrenceData>)
.map(fieldName => validateOccurrence(payload, fieldName))
.filter(validation => !!validation.error)
if (validationErrors.length > 0) {
const details = validationErrors.map(validation => `${validation.name}: ${validation.error}`).join('; ')
const error = new Error(details) as Error & { status: number }
error.status = 400
throw error
}
}
type OccurrenceLogRow = Record<string, unknown> & {
pk_data: string
table_name: string
+6 -6
View File
@@ -1,9 +1,9 @@
import { OccurrenceDetailsType, Role, User } from '../../../../frontend/src/shared/types'
import { EditableOccurrenceData, OccurrenceEditableField, Role, User } from '../../../../frontend/src/shared/types'
import { AccessError } from '../../middlewares/authorizer'
import { nowDb } from '../../utils/db'
import { ensureOccurrenceEditAccess } from '../occurrenceService'
import { ensureOccurrenceEditAccess, validateOccurrencePayload } from '../occurrenceService'
const editableOccurrenceFields = [
const editableOccurrenceFields: OccurrenceEditableField[] = [
'nis',
'pct',
'quad',
@@ -33,9 +33,7 @@ const editableOccurrenceFields = [
'do18_max',
'do18_min',
'do18_stdev',
] as const
export type EditableOccurrenceData = Partial<Pick<OccurrenceDetailsType, (typeof editableOccurrenceFields)[number]>>
]
type OccurrenceUpdateData = Record<string, unknown> & { body_mass?: bigint | null }
@@ -91,6 +89,8 @@ export const updateOccurrenceByCompositeKey = async (
return null
}
validateOccurrencePayload(editedOccurrence)
const updatedOccurrence = await nowDb.now_ls.update({
where: {
lid_species_id: {
@@ -0,0 +1,52 @@
import { describe, expect, it } from '@jest/globals'
import { validateOccurrence, occurrenceDropdownValues } from '../../../../frontend/src/shared/validators/occurrence'
import { EditableOccurrenceData } from '../../../../frontend/src/shared/types'
describe('validateOccurrence', () => {
it('accepts valid dropdown values', () => {
const payload: EditableOccurrenceData = {
id_status: occurrenceDropdownValues.idStatus[0],
qua: occurrenceDropdownValues.quantity[0],
mesowear: occurrenceDropdownValues.mesowear[0],
microwear: occurrenceDropdownValues.microwear[0],
}
const errors = (Object.keys(payload) as Array<keyof EditableOccurrenceData>)
.map(field => validateOccurrence(payload, field))
.filter(validation => validation.error)
expect(errors).toEqual([])
})
it('returns errors for invalid dropdown values', () => {
const payload: EditableOccurrenceData = {
id_status: 'invalid',
qua: 'x',
mesowear: 'foo',
microwear: 'bar',
}
const errors = (Object.keys(payload) as Array<keyof EditableOccurrenceData>)
.map(field => validateOccurrence(payload, field))
.filter(validation => validation.error)
expect(errors).toHaveLength(4)
expect(errors[0]?.error).toContain('must be one of the following values')
})
it('enforces mesowear scale consistency', () => {
const payload: EditableOccurrenceData = {
mw_scale_min: 5,
mw_scale_max: 3,
mw_value: 1,
}
expect(validateOccurrence(payload, 'mw_scale_min').error).toBe(
'Scale Minimum cannot be greater than Scale Maximum.'
)
expect(validateOccurrence(payload, 'mw_scale_max').error).toBe('Scale Maximum cannot be less than Scale Minimum.')
expect(validateOccurrence(payload, 'mw_value').error).toBe(
'Reported Value must be between Scale Minimum and Scale Maximum.'
)
})
})
+32
View File
@@ -76,6 +76,38 @@ export type OccurrenceDetailsType = {
now_oau: OccurrenceUpdate[]
}
export type OccurrenceEditableField =
| 'nis'
| 'pct'
| 'quad'
| 'mni'
| 'qua'
| 'id_status'
| 'orig_entry'
| 'source_name'
| 'body_mass'
| 'mesowear'
| 'mw_or_high'
| 'mw_or_low'
| 'mw_cs_sharp'
| 'mw_cs_round'
| 'mw_cs_blunt'
| 'mw_scale_min'
| 'mw_scale_max'
| 'mw_value'
| 'microwear'
| 'dc13_mean'
| 'dc13_n'
| 'dc13_max'
| 'dc13_min'
| 'dc13_stdev'
| 'do18_mean'
| 'do18_n'
| 'do18_max'
| 'do18_min'
| 'do18_stdev'
export type EditableOccurrenceData = Partial<Pick<OccurrenceDetailsType, OccurrenceEditableField>>
export type LocalityUpdate = Prisma.now_lau & { now_lr: LocalityReference[] } & { updates: UpdateLog[] }
export type SpeciesUpdate = Prisma.now_sau & { now_sr: SpeciesReference[] } & { updates: UpdateLog[] }
export type Museum = Omit<Prisma.com_mlist, 'used_morph' | 'used_now' | 'used_gene'>
@@ -0,0 +1,75 @@
import { EditableOccurrenceData } from '../types'
import { Validators, validator } from './validator'
export const occurrenceDropdownValues = {
idStatus: ['certain', 'uncertain', 'open nomenclature', 'indeterminate'] as const,
quantity: ['a', 'c', 'r', 'v'] as const,
mesowear: ['bil', 'mix', 'att', 'unw'] as const,
microwear: ['pit_dom', 'pit_str', 'str_dom'] as const,
}
const validateOptionalStringSelect = (label: string, value: string, options: readonly string[]) => {
if (!options.includes(value)) {
return `${label} must be one of the following values: ${options.join(', ')}.`
}
return
}
const hasNumericValue = (value: unknown): value is number => typeof value === 'number'
export const validateOccurrence = (editData: EditableOccurrenceData, fieldName: keyof EditableOccurrenceData) => {
const validators: Validators<Partial<EditableOccurrenceData>> = {
id_status: {
name: 'ID status',
condition: () => editData.id_status !== null && editData.id_status !== undefined && editData.id_status !== '',
asString: value => validateOptionalStringSelect('ID status', value, occurrenceDropdownValues.idStatus),
},
qua: {
name: 'Quantity',
condition: () => editData.qua !== null && editData.qua !== undefined && editData.qua !== '',
asString: value => validateOptionalStringSelect('Quantity', value, occurrenceDropdownValues.quantity),
},
mesowear: {
name: 'Mesowear',
condition: () => editData.mesowear !== null && editData.mesowear !== undefined && editData.mesowear !== '',
asString: value => validateOptionalStringSelect('Mesowear', value, occurrenceDropdownValues.mesowear),
},
microwear: {
name: 'Microwear',
condition: () => editData.microwear !== null && editData.microwear !== undefined && editData.microwear !== '',
asString: value => validateOptionalStringSelect('Microwear', value, occurrenceDropdownValues.microwear),
},
mw_scale_min: {
name: 'Scale Minimum',
asNumber: value => {
if (value < 0) return 'Scale Minimum cannot be negative.'
if (hasNumericValue(editData.mw_scale_max) && value > editData.mw_scale_max)
return 'Scale Minimum cannot be greater than Scale Maximum.'
return
},
},
mw_scale_max: {
name: 'Scale Maximum',
asNumber: value => {
if (value < 0) return 'Scale Maximum cannot be negative.'
if (hasNumericValue(editData.mw_scale_min) && value < editData.mw_scale_min)
return 'Scale Maximum cannot be less than Scale Minimum.'
return
},
},
mw_value: {
name: 'Reported Value',
asNumber: value => {
if (value < 0) return 'Reported Value cannot be negative.'
if (hasNumericValue(editData.mw_scale_min) && value < editData.mw_scale_min)
return 'Reported Value must be between Scale Minimum and Scale Maximum.'
if (hasNumericValue(editData.mw_scale_max) && value > editData.mw_scale_max)
return 'Reported Value must be between Scale Minimum and Scale Maximum.'
return
},
},
}
return validator<EditableOccurrenceData>(validators, editData, fieldName)
}