Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 172 additions & 11 deletions __tests__/utils.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,30 +327,191 @@ describe('controllers/utils.js idNegotiation edge cases', () => {
})

describe('controllers/utils.js getPagination', () => {
/**
* Values that are not plain decimal digits, from the vector list in #301 plus whitespace and digit
* separators. 'limit', 'skip', and the configured caps all share this one policy, so they share
* this one table.
*/
const NOT_DECIMAL_WHOLE_NUMBERS = ['abc', '10abc', '1e3', '0x10', '250.7', '2.9', '-5', ' 500', '100_000', '']

/** Assert that a query is rejected as a 400 rather than guessed at. */
const assertRejects = (query, matcher) => {
assert.throws(
() => getPagination(query),
(err) => {
assert.strictEqual(err.statusCode, 400)
assert.match(err.statusMessage, matcher)
return true
},
`${JSON.stringify(query)} should be rejected`
)
}

it('returns the default limit and skip 0 for an empty query', () => {
const result = getPagination({}, 100)
const result = getPagination({})
assert.strictEqual(result.limit, 100)
assert.strictEqual(result.skip, 0)
assert.strictEqual(getPagination({}, { defaultLimit: 50 }).limit, 50)
})

it('parses numeric string values from the query', () => {
it('parses decimal integer string values from the query', () => {
const result = getPagination({ limit: '50', skip: '10' })
assert.strictEqual(result.limit, 50)
assert.strictEqual(result.skip, 10)
})

it('falls back to defaults on non-numeric or non-positive input', () => {
const result = getPagination({ limit: 'bogus', skip: 'nope' }, 100)
assert.strictEqual(result.limit, 100)
assert.strictEqual(result.skip, 0)
it('rejects a limit that is not a whole number greater than 0', () => {
for (const limit of NOT_DECIMAL_WHOLE_NUMBERS) {
assertRejects({ limit }, /'limit' URL parameter must be a whole number greater than 0/)
}
})

it('rejects a skip that is not a whole number of 0 or greater', () => {
for (const skip of NOT_DECIMAL_WHOLE_NUMBERS) {
assertRejects({ skip }, /'skip' URL parameter must be a whole number 0 or greater/)
}
})

it('accepts skip 0, which limit does not', () => {
assert.strictEqual(getPagination({ skip: '0' }).skip, 0)
assertRejects({ limit: '0' }, /whole number greater than 0/)
})

it('rejects a repeated parameter rather than taking a guess at which one was meant', () => {
// Express hands a repeated URL parameter over as an Array. '?limit=100&limit=200' has no
// single correct reading.
assertRejects({ limit: ['100', '200'] }, /'limit' URL parameter was provided more than once/)
assertRejects({ skip: ['1', '2'] }, /'skip' URL parameter was provided more than once/)
})

it('truncates a long rejected value rather than reflecting all of it back', () => {
assert.throws(
() => getPagination({ limit: 'x'.repeat(500) }),
(err) => err.statusMessage.length < 200
)
})

it('clamps a limit too large for a Number rather than calling it not a whole number', () => {
const { limit } = getPagination({ limit: '9'.repeat(400) })
assert.strictEqual(limit, Number(capturedHeadersFor({})['Pagination-Limit-Max']))
})

it('sends a skip too large for a Number to the ceiling message, not the malformed one', () => {
assertRejects({ skip: '9'.repeat(400) }, /beyond the maximum/)
})

it('rejects a skip above the maximum rather than serving the same page forever', () => {
const original = process.env.MAX_QUERY_SKIP
try {
process.env.MAX_QUERY_SKIP = '2500'
assertRejects({ skip: '2501' }, /2501 is beyond the maximum of 2500/)
assert.strictEqual(getPagination({ skip: '2500' }).skip, 2500)
} finally {
if (original === undefined) delete process.env.MAX_QUERY_SKIP
else process.env.MAX_QUERY_SKIP = original
}
})

it('reports the applied values and the maximums on every paged response', () => {
const headers = capturedHeadersFor({ limit: '25', skip: '10' })
assert.strictEqual(headers['Pagination-Limit'], '25')
assert.strictEqual(headers['Pagination-Skip'], '10')
assert.ok(Number(headers['Pagination-Limit-Max']) > 0)
assert.ok(Number(headers['Pagination-Skip-Max']) > 0)
})

it('clamps an unreasonably large limit below the max', () => {
const huge = Number.MAX_SAFE_INTEGER
const result = getPagination({ limit: String(huge) })
assert.ok(result.limit > 0)
assert.ok(result.limit < huge, `limit should be clamped below ${huge}`)
it('reports the clamped limit, not the one that was asked for', () => {
const headers = capturedHeadersFor({ limit: '999999' })
assert.strictEqual(headers['Pagination-Limit'], headers['Pagination-Limit-Max'])
})

it('reads the caps from the environment at call time', () => {
const original = process.env.MAX_QUERY_LIMIT
try {
process.env.MAX_QUERY_LIMIT = '25'
const { limit } = getPagination({ limit: '100' })
assert.strictEqual(limit, 25)
assert.strictEqual(capturedHeadersFor({})['Pagination-Limit-Max'], '25')
} finally {
if (original === undefined) delete process.env.MAX_QUERY_LIMIT
else process.env.MAX_QUERY_LIMIT = original
}
})

it('falls back to the code default when a configured cap is unusable', () => {
const original = process.env.MAX_QUERY_LIMIT
try {
for (const configured of [...NOT_DECIMAL_WHOLE_NUMBERS, '0']) {
process.env.MAX_QUERY_LIMIT = configured
assert.strictEqual(
capturedHeadersFor({})['Pagination-Limit-Max'],
'500',
`MAX_QUERY_LIMIT='${configured}' should fall back to the code default`
)
}
} finally {
if (original === undefined) delete process.env.MAX_QUERY_LIMIT
else process.env.MAX_QUERY_LIMIT = original
}
})

it('reports the ceilings on a rejection, so a client can recover from the 400 it just got', () => {
for (const query of [{ limit: 'abc' }, { skip: '999999999' }]) {
const headers = capturedHeadersFor(query)
assert.ok(Number(headers['Pagination-Limit-Max']) > 0, `${JSON.stringify(query)} should still report the limit ceiling`)
assert.ok(Number(headers['Pagination-Skip-Max']) > 0, `${JSON.stringify(query)} should still report the skip ceiling`)
}
})

it('reports no applied page on a rejection, because none was served', () => {
const headers = capturedHeadersFor({ skip: '999999999' })
assert.strictEqual(headers['Pagination-Limit'], undefined)
assert.strictEqual(headers['Pagination-Skip'], undefined)
})

it('accepts an already-integral number, which req.query never holds but a caller might pass', () => {
const result = getPagination({ limit: 50, skip: 10 })
assert.strictEqual(result.limit, 50)
assert.strictEqual(result.skip, 10)
assertRejects({ limit: 250.7 }, /whole number greater than 0/)
})

it('reports nothing rather than throwing when res cannot set headers', () => {
for (const notAResponse of [null, 100, 'res', true, {}, { set: 'not a function' }]) {
const result = getPagination({ limit: '25', skip: '5' }, { res: notAResponse })
assert.strictEqual(result.limit, 25, `${JSON.stringify(notAResponse)} should not change the limit`)
assert.strictEqual(result.skip, 5, `${JSON.stringify(notAResponse)} should not change the skip`)
}
})

it('echoes the raw skip in the ceiling rejection, not the value it parsed to', () => {
const raw = '99999999999999999999'
assert.throws(
() => getPagination({ skip: raw }),
(err) => {
assert.strictEqual(err.statusCode, 400)
assert.match(err.statusMessage, new RegExp(`of ${raw} is beyond the maximum`))
return true
}
)
})

/**
* Run getPagination against a minimal response double and hand back the headers it set.
*
* The ceilings and the applied values arrive as two separate set() calls, so they are merged
* rather than overwritten. A rejected query still reports the ceilings, so a 400 is caught here
* instead of propagating. Anything else still throws, so a real fault is not swallowed.
*/
function capturedHeadersFor(query) {
const captured = {}
try {
getPagination(query, { res: { set: (headers) => Object.assign(captured, headers) } })
} catch (err) {
if (err.statusCode !== 400) throw err
}
return captured
}
})

describe('controllers/utils.js findLeafAnnotationsFor', () => {
Expand Down
4 changes: 2 additions & 2 deletions controllers/crud.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@ const create = async function (req, res, next) {
const query = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let props = req.body
const { limit, skip } = getPagination(req.query, 100)
if (!props || Object.keys(props).length === 0) {
//Hey now, don't ask for everything...this can happen by accident. Don't allow it.
let err = {
Expand All @@ -83,8 +82,9 @@ const query = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, { res })
try {
let matches = await db.find(props).limit(limit).skip(skip).toArray()
let matches = await db.find(props).sort({ _id: 1 }).limit(limit).skip(skip).toArray()
matches = matches.map(o => idNegotiation(o))
res.set(utils.configureLDHeadersFor(matches))
res.json(matches)
Expand Down
8 changes: 2 additions & 6 deletions controllers/gog.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,6 @@ const GOG_AGENTS = [GOG_PROD_AGENT, GOG_DEV_AGENT]
* The Bearer Token in the header must be from TinyMatt.
* The body must be formatted correctly - {"ManuscriptWitness":"witness_uri_here"}
*
* TODO? Some sort of limit and skip for large responses?
*
* @return The set of {'@id':'123', '@type':'WitnessFragment'} objects that match this criteria, as an Array
* */
const _gog_fragments_from_manuscript = async function (req, res, next) {
Expand All @@ -33,7 +31,6 @@ const _gog_fragments_from_manuscript = async function (req, res, next) {
if (!agent) return
const agentID = agent.split("/").pop()
const manID = req.body["ManuscriptWitness"]
const { limit, skip } = getPagination(req.query, 50)
let err = { message: `` }
// This request can only be made my Gallery of Glosses production apps.
if (agentID !== GOG_PROD_AGENT) {
Expand All @@ -52,6 +49,7 @@ const _gog_fragments_from_manuscript = async function (req, res, next) {
if (err.status) {
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, { defaultLimit: 50 })
try {
let matches = []
const partOfConditions = [
Expand Down Expand Up @@ -154,8 +152,6 @@ const _gog_fragments_from_manuscript = async function (req, res, next) {
* The Bearer Token in the header must be from TinyMatt.
* The body must be formatted correctly - {"ManuscriptWitness":"witness_uri_here"}
*
* TODO? Some sort of limit and skip for large responses?
*
* @return The set of {'@id':'123', '@type':'Gloss'} objects that match this criteria, as an Array
* */
const _gog_glosses_from_manuscript = async function (req, res, next) {
Expand All @@ -164,7 +160,6 @@ const _gog_glosses_from_manuscript = async function (req, res, next) {
if (!agent) return
const agentID = agent.split("/").pop()
const manID = req.body["ManuscriptWitness"]
const { limit, skip } = getPagination(req.query, 50)
let err = { message: `` }
// This request can only be made my Gallery of Glosses production apps.
if (agentID !== GOG_PROD_AGENT) {
Expand All @@ -183,6 +178,7 @@ const _gog_glosses_from_manuscript = async function (req, res, next) {
if (err.status) {
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, { defaultLimit: 50 })
try {
let matches = []
const partOfConditions = [
Expand Down
5 changes: 3 additions & 2 deletions controllers/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ const history = async function (req, res, next) {
const queryHeadRequest = async function (req, res, next) {
res.set("Content-Type", "application/json; charset=utf-8")
let props = req.body
const { limit, skip } = getPagination(req.query, 100)
const { limit, skip } = getPagination(req.query, { res })
try {
const matches = await db.find(props).limit(limit).skip(skip).toArray()
// Sorted the same way POST /query is, so the two verbs page over one order.
const matches = await db.find(props).sort({ _id: 1 }).limit(limit).skip(skip).toArray()
if (matches.length) {
const negotiated = matches.map(o => idNegotiation(o))
const size = Buffer.byteLength(JSON.stringify(negotiated))
Expand Down
16 changes: 7 additions & 9 deletions controllers/search.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { idNegotiation, getPagination } from './utils.js'
* 1. Combines both result arrays
* 2. Removes duplicates based on MongoDB _id (keeps first occurrence)
* 3. Sorts by search score in descending order (highest relevance first)
*
*
* The function handles different _id formats:
* - ObjectId objects with $oid property
* - String-based _id values
Expand All @@ -40,9 +40,7 @@ function mergeSearchResults(results1, results2) {
merged.push(result)
}
}

// Sort by score descending
return merged.sort((a, b) => (b.score || 0) - (a.score || 0))
return merged.sort((a, b) => (b.__rerum?.score ?? 0) - (a.__rerum?.score ?? 0))
}

/**
Expand Down Expand Up @@ -271,7 +269,7 @@ const searchAsWords = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, 100)
const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: searchOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
Expand Down Expand Up @@ -357,7 +355,7 @@ const searchAsPhrase = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, 100)
const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "phrase", options: phraseOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
Expand Down Expand Up @@ -435,7 +433,7 @@ const searchFuzzily = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, 100)
const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: fuzzyOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
Expand Down Expand Up @@ -529,7 +527,7 @@ const searchWildly = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, 100)
const { limit, skip } = getPagination(req.query, { res })
const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "wildcard", options: wildcardOptions }, limit, skip)
try {
const [resultsPresi3, resultsPresi2] = await Promise.all([
Expand Down Expand Up @@ -622,7 +620,7 @@ const searchAlikes = async function (req, res, next) {
}
return next(utils.createExpressError(err))
}
const { limit, skip } = getPagination(req.query, 100)
const { limit, skip } = getPagination(req.query, { res })
// Build moreLikeThis queries for both IIIF 3.0 and IIIF 2.1 indexes
const searchQuery_presi3 = [
{
Expand Down
Loading
Loading