mirror of
https://github.com/nowcommunity/nowdatabase.git
synced 2026-10-12 19:13:52 +03:00
Add occurrence edit payload validation rules
This commit is contained in:
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user