Skip to content
Merged
60 changes: 60 additions & 0 deletions backend/src/api-tests/tabLists/queryValidation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { afterAll, beforeAll, describe, expect, it } from '@jest/globals'
import { pool } from '../../utils/db'
import { login, resetDatabase, resetDatabaseTimeout, send } from '../utils'

describe('Tab list query validation and pagination', () => {
beforeAll(async () => {
await resetDatabase()
await login()
}, resetDatabaseTimeout)

afterAll(async () => {
await pool.end()
})

it('rejects invalid sorting for reference species endpoint', async () => {
const { body, status } = await send<{ message: string; errors: string[] }>(
'reference/species/10029?sorting=%5B%7B%22id%22%3A%22invalid%22%2C%22desc%22%3Afalse%7D%5D',
'GET'
)

expect(status).toBe(400)
expect(body.message).toBe('Invalid query parameters')
expect(body.errors[0]).toContain('sorting.id must be one of')
})

it('rejects non-empty server-side column filters for tab list endpoints', async () => {
const { body, status } = await send<{ message: string; errors: string[] }>(
'time-unit/localities/agenian?columnfilters=%5B%7B%22id%22%3A%22loc_name%22%2C%22value%22%3A%22x%22%7D%5D',
'GET'
)

expect(status).toBe(400)
expect(body.message).toBe('Invalid query parameters')
expect(body.errors).toContain(
'Server-side columnfilters are not supported for this endpoint. Use client-side filtering.'
)
})

it('applies pagination and sorting to reference species endpoint', async () => {
const { body, status } = await send<Record<string, unknown>[]>(
'reference/species/10029?sorting=%5B%7B%22id%22%3A%22species_name%22%2C%22desc%22%3Atrue%7D%5D&pagination=%7B%22pageIndex%22%3A0%2C%22pageSize%22%3A1%7D',
'GET'
)

expect(status).toBe(200)
expect(Array.isArray(body)).toBe(true)
expect(body.length).toBeLessThanOrEqual(1)
})

it('applies pagination to museum localities without changing authorization behavior', async () => {
const { body, status } = await send<{ localities: Record<string, unknown>[] }>(
'museum/APM?limit=1&offset=0&sorting=%5B%7B%22id%22%3A%22loc_name%22%2C%22desc%22%3Afalse%7D%5D',
'GET'
)

expect(status).toBe(200)
expect(Array.isArray(body.localities)).toBe(true)
expect(body.localities.length).toBeLessThanOrEqual(1)
})
})
13 changes: 12 additions & 1 deletion backend/src/routes/museum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { fixBigInt } from '../utils/common'
import { requireOneOf } from '../middlewares/authorizer'
import { Role, EditDataType, EditMetaData, Museum } from '../../../frontend/src/shared/types'
import { DuplicateMuseumCodeError, writeMuseum } from '../services/write/museum'
import { parseTabListQuery } from '../services/tabularQuery'

const router = Router()

Expand All @@ -14,7 +15,17 @@ router.get('/all', async (_req, res) => {

router.get('/:id', async (req, res) => {
const id = req.params.id
const museum = await getMuseumDetails(id)
const parsedQuery = parseTabListQuery({
query: req.query,
allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'],
defaultSorting: [{ id: 'loc_name', desc: false }],
})

if (!parsedQuery.ok) {
return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors })
}

const museum = await getMuseumDetails(id, parsedQuery.options)
if (!museum) return res.status(404).send()
return res.status(200).send(fixBigInt(museum))
})
Expand Down
30 changes: 28 additions & 2 deletions backend/src/routes/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { requireOneOf } from '../middlewares/authorizer'
import { Role, EditMetaData, ReferenceDetailsType, EditDataType } from '../../../frontend/src/shared/types'
import { deleteReference, writeReference } from '../services/write/reference'
import { fixBigInt } from '../utils/common'
import { parseTabListQuery } from '../services/tabularQuery'

const router = Router()

Expand Down Expand Up @@ -63,13 +64,38 @@ router.get('/:id', async (req, res) => {

router.get('/localities/:id', async (req, res) => {
const id = req.params.id
const localities = await getReferenceLocalities(id)
const parsedQuery = parseTabListQuery({
query: req.query,
allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'],
defaultSorting: [{ id: 'loc_name', desc: false }],
})

if (!parsedQuery.ok) {
return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors })
}

const localities = await getReferenceLocalities(id, parsedQuery.options)
return res.status(200).send(fixBigInt(localities))
})

router.get('/species/:id', async (req, res) => {
const id = req.params.id
const species = await getReferenceSpecies(id)
const parsedQuery = parseTabListQuery({
query: req.query,
allowedSortingColumns: ['order_name', 'family_name', 'genus_name', 'species_name', 'species_id'],
defaultSorting: [
{ id: 'order_name', desc: false },
{ id: 'family_name', desc: false },
{ id: 'genus_name', desc: false },
{ id: 'species_name', desc: false },
],
})

if (!parsedQuery.ok) {
return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors })
}

const species = await getReferenceSpecies(id, parsedQuery.options)
return res.status(200).send(fixBigInt(species))
})

Expand Down
17 changes: 16 additions & 1 deletion backend/src/routes/timeBound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
} from '../services/timeBound'
import { deleteTimeBound, writeTimeBound } from '../services/write/timeBound'
import { fixBigInt } from '../utils/common'
import { parseTabListQuery } from '../services/tabularQuery'

const router = Router()

Expand All @@ -26,7 +27,21 @@ router.get('/:id', async (req, res) => {

router.get('/time-units/:id', async (req, res) => {
const id = parseInt(req.params.id)
const timeUnits = await getTimeBoundTimeUnits(id)
const parsedQuery = parseTabListQuery({
query: req.query,
allowedSortingColumns: ['tu_name', 'tu_display_name', 'rank', 'sequence', 'tu_comment', 'up_bnd', 'low_bnd'],
defaultSorting: [
{ id: 'rank', desc: false },
{ id: 'sequence', desc: false },
{ id: 'tu_name', desc: false },
],
})

if (!parsedQuery.ok) {
return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors })
}

const timeUnits = await getTimeBoundTimeUnits(id, parsedQuery.options)
return res.status(200).send(fixBigInt(timeUnits))
})

Expand Down
13 changes: 12 additions & 1 deletion backend/src/routes/timeUnit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { getTimeBoundDetails, validateEntireTimeBound } from '../services/timeBo
import { ConflictError, DuplicateTimeUnitError, deleteTimeUnit, writeTimeUnit } from '../services/write/timeUnit'
import { fixBigInt } from '../utils/common'
import { writeTimeBound } from '../services/write/timeBound'
import { parseTabListQuery } from '../services/tabularQuery'

const router = Router()

Expand Down Expand Up @@ -45,7 +46,17 @@ router.get('/:id', async (req, res) => {

router.get('/localities/:id', async (req, res) => {
const id = req.params.id
const localities = await getTimeUnitLocalities(id)
const parsedQuery = parseTabListQuery({
query: req.query,
allowedSortingColumns: ['loc_name', 'country', 'max_age', 'min_age', 'lid'],
defaultSorting: [{ id: 'loc_name', desc: false }],
})

if (!parsedQuery.ok) {
return res.status(400).send({ message: 'Invalid query parameters', errors: parsedQuery.errors })
}

const localities = await getTimeUnitLocalities(id, parsedQuery.options)
return res.status(200).send(fixBigInt(localities))
})

Expand Down
12 changes: 10 additions & 2 deletions backend/src/services/museum.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { Locality, Museum, EditDataType, EditMetaData } from '../../../frontend/src/shared/types'
import { nowDb } from '../utils/db'
import { ValidationObject } from '../../../frontend/src/shared/validators/validator'
import Prisma from '../../prisma/generated/now_test_client'
import type Prisma from '../../prisma/generated/now_test_client'
import { validateMuseum } from '../../../frontend/src/shared/validators/museum'
import { TabListQueryOptions } from './tabularQuery'

export const getAllMuseums = async () => {
const result = await nowDb.com_mlist.findMany({})
return result
}

export const getMuseumDetails = async (id: string) => {
export const getMuseumDetails = async (id: string, options?: TabListQueryOptions) => {
const museum = await nowDb.com_mlist.findUnique({
where: { museum: id },
})
Expand All @@ -23,6 +24,10 @@ export const getMuseumDetails = async (id: string) => {

const localityIds = localityLinks.map(link => link.lid)

const orderBy = options?.sorting.map(sort => ({
[sort.id]: sort.desc ? 'desc' : 'asc',
}))

const localitiesResult = await nowDb.now_loc.findMany({
where: { lid: { in: localityIds } },
select: {
Expand Down Expand Up @@ -80,6 +85,9 @@ export const getMuseumDetails = async (id: string) => {
select: { synonym: true },
},
},
orderBy,
skip: options?.skip,
take: options?.take,
})

const localities: Locality[] = localitiesResult.map(locality => {
Expand Down
21 changes: 18 additions & 3 deletions backend/src/services/reference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import {
ReferenceFieldDisplayNames,
validateReference,
} from '../../../frontend/src/shared/validators/reference'
import type { ref_ref } from '../../prisma/generated/now_test_client'
import { type ref_ref } from '../../prisma/generated/now_test_client'
import { TabListQueryOptions } from './tabularQuery'

type ReferenceTypeFieldName = { field_name: string | null; ref_field_name: string | null }

Expand Down Expand Up @@ -65,8 +66,12 @@ export const getReferenceDetails = async (id: number) => {
}

// Fetch localities that have been updated by the given reference id
export const getReferenceLocalities = async (id: string) => {
export const getReferenceLocalities = async (id: string, options?: TabListQueryOptions) => {
// TODO: Check if user has access
const orderBy = options?.sorting.map(sort => ({
[sort.id]: sort.desc ? 'desc' : 'asc',
}))

const result = await nowDb.now_loc.findMany({
where: {
now_lau: {
Expand All @@ -77,13 +82,20 @@ export const getReferenceLocalities = async (id: string) => {
},
},
},
orderBy,
skip: options?.skip,
take: options?.take,
})
return result
}

// Fetch species that have been updated by the given reference id
export const getReferenceSpecies = async (id: string) => {
export const getReferenceSpecies = async (id: string, options?: TabListQueryOptions) => {
// TODO: Check if user has access
const orderBy = options?.sorting.map(sort => ({
[sort.id]: sort.desc ? 'desc' : 'asc',
}))

const result = await nowDb.com_species.findMany({
where: {
now_sau: {
Expand All @@ -94,6 +106,9 @@ export const getReferenceSpecies = async (id: string) => {
},
},
},
orderBy,
skip: options?.skip,
take: options?.take,
})
return result
}
Expand Down
Loading
Loading