diff --git a/__tests__/routes_mounted.test.js b/__tests__/routes_mounted.test.js index edd53716..0123514d 100644 --- a/__tests__/routes_mounted.test.js +++ b/__tests__/routes_mounted.test.js @@ -6,111 +6,139 @@ */ import request from "supertest" -import api_routes from "../routes/api-routes.js" import app from "../app.js" import fs from "fs" -let app_stack = app.router.stack -let api_stack = api_routes.stack - -/** - * Check if a route exists in the Express app - * @param {Array} stack - The router stack to search - * @param {string} testPath - The path to test for - * @returns {boolean} - True if the route exists - */ -function routeExists(stack, testPath) { - for (const layer of stack) { - // Check if layer has matchers (Express 5) - if (layer.matchers && layer.matchers.length > 0) { - const matcher = layer.matchers[0] - const match = matcher(testPath) - if (match && match.path) return true - } - // Also check route.path directly if it exists - if (layer.route && layer.route.path) { - if (layer.route.path === testPath || layer.route.path.includes(testPath)) return true - } - } - return false -} - describe('Check to see that all expected top level route patterns exist.', () => { - it('/v1 -- mounted ', () => { - expect(routeExists(app_stack, '/v1')).toBe(true) + it('/v1 -- mounted ', async () => { + const response = await request(app).get('/v1') + expect(response.statusCode).not.toBe(404) }) - it('/client -- mounted ', () => { - expect(routeExists(app_stack, '/client')).toBe(true) + it('/client -- mounted ', async () => { + const response = await request(app).get('/client/register') + expect(response.statusCode).not.toBe(404) }) - it('/v1/id/{_id} -- mounted', () => { - expect(routeExists(api_stack, '/id')).toBe(true) + it('/v1/id/{_id} -- mounted', async () => { + const response = await request(app).get('/v1/id/test-mounted-id') + // Mounted route with unknown id should 404 (not an unmapped endpoint 404) + expect(response.statusCode).toBe(404) }) - it('/v1/since/{_id} -- mounted', () => { - expect(routeExists(api_stack, '/since')).toBe(true) + it('/v1/since/{_id} -- mounted', async () => { + const response = await request(app).get('/v1/since/test-mounted-id') + // Mounted route with unknown id should 404 + expect(response.statusCode).toBe(404) }) - it('/v1/history/{_id} -- mounted', () => { - expect(routeExists(api_stack, '/history')).toBe(true) + it('/v1/history/{_id} -- mounted', async () => { + const response = await request(app).get('/v1/history/test-mounted-id') + // Mounted route with unknown id should 404 + expect(response.statusCode).toBe(404) }) }) describe('Check to see that all /v1/api/ route patterns exist.', () => { - it('/v1/api/query -- mounted ', () => { - expect(routeExists(api_stack, '/api/query')).toBe(true) + it('/v1/api/query -- mounted ', async () => { + const response = await request(app) + .post('/v1/api/query') + .set('Content-Type', 'application/json') + .send({ mounted: true }) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/create -- mounted ', () => { - expect(routeExists(api_stack, '/api/create')).toBe(true) + it('/v1/api/create -- mounted ', async () => { + const response = await request(app) + .post('/v1/api/create') + .set('Content-Type', 'application/json') + .send({ mounted: true }) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/bulkCreate -- mounted ', () => { - expect(routeExists(api_stack, '/api/bulkCreate')).toBe(true) + it('/v1/api/bulkCreate -- mounted ', async () => { + const response = await request(app) + .post('/v1/api/bulkCreate') + .set('Content-Type', 'application/json') + .send([{ mounted: true }]) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/update -- mounted ', () => { - expect(routeExists(api_stack, '/api/update')).toBe(true) + it('/v1/api/update -- mounted ', async () => { + const response = await request(app) + .put('/v1/api/update') + .set('Content-Type', 'application/json') + .send({ mounted: true }) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/bulkUpdate -- mounted ', () => { - expect(routeExists(api_stack, '/api/bulkUpdate')).toBe(true) + it('/v1/api/bulkUpdate -- mounted ', async () => { + const response = await request(app) + .put('/v1/api/bulkUpdate') + .set('Content-Type', 'application/json') + .send([{ mounted: true }]) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/overwrite -- mounted ', () => { - expect(routeExists(api_stack, '/api/overwrite')).toBe(true) + it('/v1/api/overwrite -- mounted ', async () => { + const response = await request(app) + .post('/v1/api/overwrite') + .set('Content-Type', 'application/json') + .send({ mounted: true }) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/patch -- mounted ', () => { - expect(routeExists(api_stack, '/api/patch')).toBe(true) + it('/v1/api/patch -- mounted ', async () => { + const response = await request(app) + .patch('/v1/api/patch') + .set('Content-Type', 'application/json') + .send({ mounted: true }) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/set -- mounted ', () => { - expect(routeExists(api_stack, '/api/set')).toBe(true) + it('/v1/api/set -- mounted ', async () => { + const response = await request(app) + .patch('/v1/api/set') + .set('Content-Type', 'application/json') + .send({ mounted: true }) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/unset -- mounted ', () => { - expect(routeExists(api_stack, '/api/unset')).toBe(true) + it('/v1/api/unset -- mounted ', async () => { + const response = await request(app) + .patch('/v1/api/unset') + .set('Content-Type', 'application/json') + .send({ mounted: true }) + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/delete/{id} -- mounted ', () => { - expect(routeExists(api_stack, '/api/delete')).toBe(true) + it('/v1/api/delete/{id} -- mounted ', async () => { + const response = await request(app).delete('/v1/api/delete/test-mounted-id') + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/release/{id} -- mounted ', () => { - expect(routeExists(api_stack, '/api/release')).toBe(true) + it('/v1/api/release/{id} -- mounted ', async () => { + const response = await request(app).patch('/v1/api/release/test-mounted-id') + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/search -- mounted ', () => { - expect(routeExists(api_stack, '/api/search')).toBe(true) + it('/v1/api/search -- mounted ', async () => { + const response = await request(app) + .post('/v1/api/search') + .set('Content-Type', 'text/plain') + .send('mounted search') + expect(response.statusCode).not.toBe(404) }) - it('/v1/api/search/phrase -- mounted ', () => { - expect(routeExists(api_stack, '/api/search/phrase')).toBe(true) + it('/v1/api/search/phrase -- mounted ', async () => { + const response = await request(app) + .post('/v1/api/search/phrase') + .set('Content-Type', 'text/plain') + .send('mounted phrase search') + expect(response.statusCode).not.toBe(404) }) }) @@ -142,4 +170,4 @@ describe('Check to see that critical repo files are present', () => { expect(fs.existsSync(filePath+"jest.config.js")).toBeTruthy() expect(fs.existsSync(filePath+"package.json")).toBeTruthy() }) -}) \ No newline at end of file +}) diff --git a/controllers/bulk.js b/controllers/bulk.js index c2bd5759..83ba7f06 100644 --- a/controllers/bulk.js +++ b/controllers/bulk.js @@ -33,7 +33,8 @@ const bulkCreate = async function (req, res, next) { // Each item must be valid JSON, but can't be an array. if(Array.isArray(d) || typeof d !== "object") return d try { - JSON.parse(JSON.stringify(d)) + // Validate that the entry is structured-cloneable; result is intentionally discarded. + structuredClone(d) } catch (err) { return d } @@ -120,7 +121,8 @@ const bulkUpdate = async function (req, res, next) { // Each item must be valid JSON, but can't be an array. if(Array.isArray(d) || typeof d !== "object") return d try { - JSON.parse(JSON.stringify(d)) + // Validate that the entry is structured-cloneable; result is intentionally discarded. + structuredClone(d) } catch (err) { return d } diff --git a/controllers/crud.js b/controllers/crud.js index f89032fb..85feba2a 100644 --- a/controllers/crud.js +++ b/controllers/crud.js @@ -6,7 +6,7 @@ */ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, idNegotiation, generateSlugId, ObjectID, getAgentClaim, parseDocumentID } from './utils.js' +import { _contextid, idNegotiation, getPagination, generateSlugId, ObjectID, getAgentClaim, parseDocumentID } from './utils.js' /** * Create a new Linked Open Data object in RERUM v1. @@ -37,7 +37,7 @@ const create = async function (req, res, next) { let generatorAgent = getAgentClaim(req, next) if (!generatorAgent) return let context = req.body["@context"] ? { "@context": req.body["@context"] } : {} - let provided = JSON.parse(JSON.stringify(req.body)) + let provided = structuredClone(req.body) let rerumProp = { "__rerum": utils.configureRerumOptions(generatorAgent, provided, false, false)["__rerum"] } if(slug){ rerumProp.__rerum.slug = slug @@ -55,7 +55,7 @@ const create = async function (req, res, next) { let result = await db.insertOne(newObject) res.set(utils.configureWebAnnoHeadersFor(newObject)) newObject = idNegotiation(newObject) - newObject.new_obj_state = JSON.parse(JSON.stringify(newObject)) + newObject.new_obj_state = structuredClone(newObject) res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"]) res.status(201) res.json(newObject) @@ -74,8 +74,7 @@ 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 = parseInt(req.query.limit ?? 100) - const skip = parseInt(req.query.skip ?? 0) + 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 = { diff --git a/controllers/delete.js b/controllers/delete.js index b54ecf7d..caa3ced8 100644 --- a/controllers/delete.js +++ b/controllers/delete.js @@ -33,7 +33,7 @@ const deleteObj = async function(req, res, next) { return next(utils.createExpressError(error)) } if (null !== originalObject) { - let safe_original = JSON.parse(JSON.stringify(originalObject)) + let safe_original = structuredClone(originalObject) if (utils.isDeleted(safe_original)) { err = Object.assign(err, { message: `The object you are trying to delete is already deleted. ${err.message}`, @@ -57,7 +57,7 @@ const deleteObj = async function(req, res, next) { } let preserveID = safe_original["@id"] let deletedFlag = {} //The __deleted flag is a JSONObject - deletedFlag["object"] = JSON.parse(JSON.stringify(originalObject)) + deletedFlag["object"] = structuredClone(originalObject) deletedFlag["deletor"] = agentRequestingDelete deletedFlag["time"] = new Date(Date.now()).toISOString().replace("Z", "") let deletedObject = { @@ -121,7 +121,7 @@ async function healHistoryTree(obj) { const nextIdForQuery = parseDocumentID(nextID) const objToUpdate = await db.findOne({"$or":[{"_id": nextIdForQuery}, {"__rerum.slug": nextIdForQuery}]}) if (null !== objToUpdate) { - let fixHistory = JSON.parse(JSON.stringify(objToUpdate)) + let fixHistory = structuredClone(objToUpdate) if (objToDeleteisRoot) { //This means this next object must become root. //Strictly, all history trees must have num(root) > 0. @@ -154,7 +154,7 @@ async function healHistoryTree(obj) { let previousIdForQuery = parseDocumentID(previous_id) const objToUpdate2 = await db.findOne({"$or":[{"_id": previousIdForQuery}, {"__rerum.slug": previousIdForQuery}]}) if (null !== objToUpdate2) { - let fixHistory2 = JSON.parse(JSON.stringify(objToUpdate2)) + let fixHistory2 = structuredClone(objToUpdate2) let origNextArray = fixHistory2["__rerum"]["history"]["next"] let newNextArray = [...origNextArray] newNextArray = newNextArray.filter(id => id !== obj["@id"]) @@ -193,7 +193,7 @@ async function newTreePrime(obj) { // fail silently } for (const d of descendants) { - let objWithUpdate = JSON.parse(JSON.stringify(d)) + let objWithUpdate = structuredClone(d) objWithUpdate["__rerum"]["history"]["prime"] = primeID let result = await db.replaceOne({ "_id": d["_id"] }, objWithUpdate) if (result.modifiedCount === 0) { diff --git a/controllers/gog.js b/controllers/gog.js index 7b22584c..b9b11857 100644 --- a/controllers/gog.js +++ b/controllers/gog.js @@ -8,7 +8,7 @@ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' +import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation } from './utils.js' /** * THIS IS SPECIFICALLY FOR 'Gallery of Glosses' @@ -27,8 +27,7 @@ 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 = parseInt(req.query.limit ?? 50) - const skip = parseInt(req.query.skip ?? 0) + const { limit, skip } = getPagination(req.query, 50) let err = { message: `` } // This request can only be made my Gallery of Glosses production apps. if (agentID !== "61043ad4ffce846a83e700dd") { @@ -159,8 +158,7 @@ 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 = parseInt(req.query.limit ?? 50) - const skip = parseInt(req.query.skip ?? 0) + const { limit, skip } = getPagination(req.query, 50) let err = { message: `` } // This request can only be made my Gallery of Glosses production apps. if (agentID !== "61043ad4ffce846a83e700dd") { @@ -389,7 +387,7 @@ const expand = async function(primitiveEntity, GENERATOR=undefined, CREATOR=unde }) // Combine the Annotation bodies with the primitive object - let expandedEntity = JSON.parse(JSON.stringify(primitiveEntity)) + let expandedEntity = structuredClone(primitiveEntity) for(const anno of matches){ const body = anno.body let keys = Object.keys(body) diff --git a/controllers/history.js b/controllers/history.js index e04e9cf4..5f72083e 100644 --- a/controllers/history.js +++ b/controllers/history.js @@ -8,7 +8,7 @@ import { newID, isValidID, db } from '../database/index.js' import utils from '../utils.js' -import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, getAllVersions, getAllAncestors, getAllDescendants } from './utils.js' +import { _contextid, ObjectID, getAgentClaim, getPagination, parseDocumentID, idNegotiation, getAllVersions, getAllAncestors, getAllDescendants } from './utils.js' /** * Public facing servlet to gather for all versions downstream from a provided `key object`. @@ -90,9 +90,13 @@ const idHeadRequest = async function (req, res, next) { try { let match = await db.findOne({"$or":[{"_id": id}, {"__rerum.slug": id}]}) if (match) { - const size = Buffer.byteLength(JSON.stringify(match)) + // Use res.end() instead of res.sendStatus(200) — sendStatus writes "OK" as the body + // and overwrites Content-Type and Content-Length. HEAD must preserve our manual headers. + // Mirror the GET pipeline (idNegotiation) so Content-Length matches the GET payload. + const negotiated = idNegotiation(match) + const size = Buffer.byteLength(JSON.stringify(negotiated)) res.set("Content-Length", size) - res.sendStatus(200) + res.status(200).end() return } let err = { @@ -112,12 +116,14 @@ const idHeadRequest = 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) try { - let matches = await db.find(props).toArray() + const matches = await db.find(props).limit(limit).skip(skip).toArray() if (matches.length) { - const size = Buffer.byteLength(JSON.stringify(matches)) + const negotiated = matches.map(o => idNegotiation(o)) + const size = Buffer.byteLength(JSON.stringify(negotiated)) res.set("Content-Length", size) - res.sendStatus(200) + res.status(200).end() return } let err = { @@ -157,13 +163,15 @@ const sinceHeadRequest = async function (req, res, next) { }) let descendants = getAllDescendants(all, obj, []) if (descendants.length) { - const size = Buffer.byteLength(JSON.stringify(descendants)) + const negotiated = descendants.map(o => idNegotiation(o)) + const size = Buffer.byteLength(JSON.stringify(negotiated)) res.set("Content-Length", size) - res.sendStatus(200) + res.status(200).end() return } - res.set("Content-Length", 0) - res.sendStatus(200) + // GET returns "[]" for the empty case — match its byte length. + res.set("Content-Length", Buffer.byteLength("[]")) + res.status(200).end() } /** @@ -193,13 +201,15 @@ const historyHeadRequest = async function (req, res, next) { }) let ancestors = getAllAncestors(all, obj, []) if (ancestors.length) { - const size = Buffer.byteLength(JSON.stringify(ancestors)) + const negotiated = ancestors.map(o => idNegotiation(o)) + const size = Buffer.byteLength(JSON.stringify(negotiated)) res.set("Content-Length", size) - res.sendStatus(200) + res.status(200).end() return } - res.set("Content-Length", 0) - res.sendStatus(200) + // GET returns "[]" for the empty case — match its byte length. + res.set("Content-Length", Buffer.byteLength("[]")) + res.status(200).end() } export { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } diff --git a/controllers/overwrite.js b/controllers/overwrite.js index 8a1e5a17..3fd62e6a 100644 --- a/controllers/overwrite.js +++ b/controllers/overwrite.js @@ -19,7 +19,7 @@ import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation } f const overwrite = async function (req, res, next) { let err = { message: `` } res.set("Content-Type", "application/json; charset=utf-8") - let objectReceived = JSON.parse(JSON.stringify(req.body)) + let objectReceived = structuredClone(req.body) let agentRequestingOverwrite = getAgentClaim(req, next) if (!agentRequestingOverwrite) return const receivedID = objectReceived["@id"] ?? objectReceived.id @@ -93,7 +93,7 @@ const overwrite = async function (req, res, next) { res.set('Current-Overwritten-Version', rerumProp["__rerum"].isOverwritten) res.set(utils.configureWebAnnoHeadersFor(newObject)) newObject = idNegotiation(newObject) - newObject.new_obj_state = JSON.parse(JSON.stringify(newObject)) + newObject.new_obj_state = structuredClone(newObject) res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"]) res.json(newObject) return diff --git a/controllers/patchSet.js b/controllers/patchSet.js index b3459a7d..b892b555 100644 --- a/controllers/patchSet.js +++ b/controllers/patchSet.js @@ -21,7 +21,7 @@ import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, al const patchSet = async function (req, res, next) { let err = { message: `` } res.set("Content-Type", "application/json; charset=utf-8") - let objectReceived = JSON.parse(JSON.stringify(req.body)) + let objectReceived = structuredClone(req.body) let originalContext let patchedObject = {} let generatorAgent = getAgentClaim(req, next) @@ -50,7 +50,7 @@ const patchSet = async function (req, res, next) { }) } else { - patchedObject = JSON.parse(JSON.stringify(originalObject)) + patchedObject = structuredClone(originalObject) if(_contextid(originalObject["@context"])) { // If the original object has a context that needs id protected, make sure you don't set it. delete objectReceived.id @@ -73,7 +73,7 @@ const patchSet = async function (req, res, next) { //Just hand back the object. The resulting of setting nothing is the object from the request body. res.set(utils.configureWebAnnoHeadersFor(originalObject)) originalObject = idNegotiation(originalObject) - originalObject.new_obj_state = JSON.parse(JSON.stringify(originalObject)) + originalObject.new_obj_state = structuredClone(originalObject) res.location(originalObject[_contextid(originalObject["@context"]) ? "id":"@id"]) res.status(200) res.json(originalObject) @@ -93,7 +93,7 @@ const patchSet = async function (req, res, next) { //Success, the original object has been updated. res.set(utils.configureWebAnnoHeadersFor(newObject)) newObject = idNegotiation(newObject) - newObject.new_obj_state = JSON.parse(JSON.stringify(newObject)) + newObject.new_obj_state = structuredClone(newObject) res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"]) res.status(200) res.json(newObject) diff --git a/controllers/patchUnset.js b/controllers/patchUnset.js index 669b19d8..faeaf292 100644 --- a/controllers/patchUnset.js +++ b/controllers/patchUnset.js @@ -21,7 +21,7 @@ import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, al const patchUnset = async function (req, res, next) { let err = { message: `` } res.set("Content-Type", "application/json; charset=utf-8") - let objectReceived = JSON.parse(JSON.stringify(req.body)) + let objectReceived = structuredClone(req.body) let patchedObject = {} let generatorAgent = getAgentClaim(req, next) if (!generatorAgent) return @@ -49,7 +49,7 @@ const patchUnset = async function (req, res, next) { }) } else { - patchedObject = JSON.parse(JSON.stringify(originalObject)) + patchedObject = structuredClone(originalObject) delete objectReceived._id //can't unset this delete objectReceived.__rerum //can't unset this delete objectReceived["@id"] //can't unset this @@ -75,7 +75,7 @@ const patchUnset = async function (req, res, next) { //Just hand back the object. The resulting of unsetting nothing is the object. res.set(utils.configureWebAnnoHeadersFor(originalObject)) originalObject = idNegotiation(originalObject) - originalObject.new_obj_state = JSON.parse(JSON.stringify(originalObject)) + originalObject.new_obj_state = structuredClone(originalObject) res.location(originalObject[_contextid(originalObject["@context"]) ? "id":"@id"]) res.status(200) res.json(originalObject) @@ -97,7 +97,7 @@ const patchUnset = async function (req, res, next) { //Success, the original object has been updated. res.set(utils.configureWebAnnoHeadersFor(newObject)) newObject = idNegotiation(newObject) - newObject.new_obj_state = JSON.parse(JSON.stringify(newObject)) + newObject.new_obj_state = structuredClone(newObject) res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"]) res.status(200) res.json(newObject) diff --git a/controllers/patchUpdate.js b/controllers/patchUpdate.js index 5e7c9c27..74c97d11 100644 --- a/controllers/patchUpdate.js +++ b/controllers/patchUpdate.js @@ -20,7 +20,7 @@ import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, al const patchUpdate = async function (req, res, next) { let err = { message: `` } res.set("Content-Type", "application/json; charset=utf-8") - let objectReceived = JSON.parse(JSON.stringify(req.body)) + let objectReceived = structuredClone(req.body) let patchedObject = {} let generatorAgent = getAgentClaim(req, next) if (!generatorAgent) return @@ -48,7 +48,7 @@ const patchUpdate = async function (req, res, next) { }) } else { - patchedObject = JSON.parse(JSON.stringify(originalObject)) + patchedObject = structuredClone(originalObject) delete objectReceived.__rerum //can't patch this delete objectReceived._id //can't patch this delete objectReceived["@id"] //can't patch this @@ -74,7 +74,7 @@ const patchUpdate = async function (req, res, next) { //Just hand back the object. The resulting of patching nothing is the object unchanged. res.set(utils.configureWebAnnoHeadersFor(originalObject)) originalObject = idNegotiation(originalObject) - originalObject.new_obj_state = JSON.parse(JSON.stringify(originalObject)) + originalObject.new_obj_state = structuredClone(originalObject) res.location(originalObject[_contextid(originalObject["@context"]) ? "id":"@id"]) res.status(200) res.json(originalObject) @@ -96,7 +96,7 @@ const patchUpdate = async function (req, res, next) { //Success, the original object has been updated. res.set(utils.configureWebAnnoHeadersFor(newObject)) newObject = idNegotiation(newObject) - newObject.new_obj_state = JSON.parse(JSON.stringify(newObject)) + newObject.new_obj_state = structuredClone(newObject) res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"]) res.status(200) res.json(newObject) diff --git a/controllers/putUpdate.js b/controllers/putUpdate.js index 04d7f436..ab10020a 100644 --- a/controllers/putUpdate.js +++ b/controllers/putUpdate.js @@ -22,7 +22,7 @@ import { _contextid, ObjectID, getAgentClaim, parseDocumentID, idNegotiation, al const putUpdate = async function (req, res, next) { let err = { message: `` } res.set("Content-Type", "application/json; charset=utf-8") - let objectReceived = JSON.parse(JSON.stringify(req.body)) + let objectReceived = structuredClone(req.body) let generatorAgent = getAgentClaim(req, next) if (!generatorAgent) return const idReceived = objectReceived["@id"] ?? objectReceived.id @@ -69,7 +69,7 @@ const putUpdate = async function (req, res, next) { //Success, the original object has been updated. res.set(utils.configureWebAnnoHeadersFor(newObject)) newObject = idNegotiation(newObject) - newObject.new_obj_state = JSON.parse(JSON.stringify(newObject)) + newObject.new_obj_state = structuredClone(newObject) res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"]) res.status(200) res.json(newObject) @@ -107,7 +107,7 @@ const putUpdate = async function (req, res, next) { async function _import(req, res, next) { let err = { message: `` } res.set("Content-Type", "application/json; charset=utf-8") - let objectReceived = JSON.parse(JSON.stringify(req.body)) + let objectReceived = structuredClone(req.body) let generatorAgent = getAgentClaim(req, next) if (!generatorAgent) return const id = ObjectID() @@ -125,7 +125,7 @@ async function _import(req, res, next) { let result = await db.insertOne(newObject) res.set(utils.configureWebAnnoHeadersFor(newObject)) newObject = idNegotiation(newObject) - newObject.new_obj_state = JSON.parse(JSON.stringify(newObject)) + newObject.new_obj_state = structuredClone(newObject) res.location(newObject[_contextid(newObject["@context"]) ? "id":"@id"]) res.status(200) res.json(newObject) diff --git a/controllers/release.js b/controllers/release.js index 58b7c5d4..afd466e3 100644 --- a/controllers/release.js +++ b/controllers/release.js @@ -44,7 +44,7 @@ const release = async function (req, res, next) { catch (error) { return next(utils.createExpressError(error)) } - let safe_original = JSON.parse(JSON.stringify(originalObject)) + let safe_original = structuredClone(originalObject) let previousReleasedID = safe_original.__rerum.releases.previous let nextReleases = safe_original.__rerum.releases.next @@ -108,7 +108,7 @@ const release = async function (req, res, next) { res.set(utils.configureWebAnnoHeadersFor(releasedObject)) console.log(releasedObject._id+" has been released") releasedObject = idNegotiation(releasedObject) - releasedObject.new_obj_state = JSON.parse(JSON.stringify(releasedObject)) + releasedObject.new_obj_state = structuredClone(releasedObject) res.location(releasedObject[_contextid(releasedObject["@context"]) ? "id":"@id"]) res.json(releasedObject) return diff --git a/controllers/search.js b/controllers/search.js index f90d935c..816a1aa6 100644 --- a/controllers/search.js +++ b/controllers/search.js @@ -6,7 +6,7 @@ */ import { db } from '../database/index.js' import utils from '../utils.js' -import { idNegotiation } from './utils.js' +import { idNegotiation, getPagination } from './utils.js' /** * Merges and deduplicates results from multiple MongoDB Atlas Search index queries. @@ -271,8 +271,7 @@ const searchAsWords = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const limit = parseInt(req.query.limit ?? 100) - const skip = parseInt(req.query.skip ?? 0) + const { limit, skip } = getPagination(req.query, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: searchOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -358,8 +357,7 @@ const searchAsPhrase = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const limit = parseInt(req.query.limit ?? 100) - const skip = parseInt(req.query.skip ?? 0) + const { limit, skip } = getPagination(req.query, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "phrase", options: phraseOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -437,8 +435,7 @@ const searchFuzzily = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const limit = parseInt(req.query.limit ?? 100) - const skip = parseInt(req.query.skip ?? 0) + const { limit, skip } = getPagination(req.query, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "text", options: fuzzyOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -532,8 +529,7 @@ const searchWildly = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const limit = parseInt(req.query.limit ?? 100) - const skip = parseInt(req.query.skip ?? 0) + const { limit, skip } = getPagination(req.query, 100) const [queryPresi3, queryPresi2] = buildDualIndexQueries(searchText, { type: "wildcard", options: wildcardOptions }, limit, skip) try { const [resultsPresi3, resultsPresi2] = await Promise.all([ @@ -626,8 +622,7 @@ const searchAlikes = async function (req, res, next) { } return next(utils.createExpressError(err)) } - const limit = parseInt(req.query.limit ?? 100) - const skip = parseInt(req.query.skip ?? 0) + const { limit, skip } = getPagination(req.query, 100) // Build moreLikeThis queries for both IIIF 3.0 and IIIF 2.1 indexes const searchQuery_presi3 = [ { diff --git a/controllers/utils.js b/controllers/utils.js index 602d372e..f995e040 100644 --- a/controllers/utils.js +++ b/controllers/utils.js @@ -9,6 +9,24 @@ import utils from '../utils.js' const ObjectID = newID +const MAX_QUERY_LIMIT = Number.parseInt(process.env.RERUM_MAX_QUERY_LIMIT ?? 500, 10) +const MAX_QUERY_SKIP = Number.parseInt(process.env.RERUM_MAX_QUERY_SKIP ?? 100000, 10) + +function clampNonNegativeInt(value, fallback, max) { + const parsed = Number.parseInt(value, 10) + if (!Number.isFinite(parsed) || parsed <= 0) return fallback + return parsed > max ? max : parsed +} + +function getPagination(query = {}, defaultLimit = 100) { + const limitMax = Number.isFinite(MAX_QUERY_LIMIT) && MAX_QUERY_LIMIT > 0 ? MAX_QUERY_LIMIT : 500 + const skipMax = Number.isFinite(MAX_QUERY_SKIP) && MAX_QUERY_SKIP >= 0 ? MAX_QUERY_SKIP : 100000 + const safeDefaultLimit = defaultLimit > 0 ? defaultLimit : 100 + const limit = clampNonNegativeInt(query.limit, safeDefaultLimit, limitMax) + const skip = clampNonNegativeInt(query.skip, 0, skipMax) + return { limit, skip } +} + /** * Check if a @context value contains a known @id-id mapping context * @@ -52,7 +70,7 @@ const idNegotiation = function (resBody) { const _id = resBody._id delete resBody._id if(!resBody["@context"]) return resBody - let modifiedResBody = JSON.parse(JSON.stringify(resBody)) + let modifiedResBody = structuredClone(resBody) const context = { "@context": resBody["@context"] } if(_contextid(resBody["@context"])) { delete resBody["@id"] @@ -182,7 +200,7 @@ async function getAllVersions(obj) { let rootObj if (primeID === "root") { //The obj passed in is root. So it is the rootObj we need. - rootObj = JSON.parse(JSON.stringify(obj)) + rootObj = structuredClone(obj) } else if (primeID) { //The obj passed in knows the ID of root, grab it from Mongo //Use _id for indexed query performance instead of @id @@ -299,7 +317,7 @@ async function establishReleasesTree(releasing) { const descendants = getAllDescendants(all, releasing, []) const ancestors = getAllAncestors(all, releasing, []) for(const d of descendants){ - let safe_descendant = JSON.parse(JSON.stringify(d)) + let safe_descendant = structuredClone(d) let d_id = safe_descendant._id safe_descendant.__rerum.releases.previous = releasing["@id"] let result @@ -317,7 +335,7 @@ async function establishReleasesTree(releasing) { } } for(const a of ancestors){ - let safe_ancestor = JSON.parse(JSON.stringify(a)) + let safe_ancestor = structuredClone(a) let a_id = safe_ancestor._id if(safe_ancestor.__rerum.releases.next.indexOf(releasing["@id"]) === -1){ safe_ancestor.__rerum.releases.next.push(releasing["@id"]) @@ -359,7 +377,7 @@ async function healReleasesTree(releasing) { const descendants = getAllDescendants(all, releasing, []) const ancestors = getAllAncestors(all, releasing, []) for(const d of descendants){ - let safe_descendant = JSON.parse(JSON.stringify(d)) + let safe_descendant = structuredClone(d) let d_id = safe_descendant._id if(d.__rerum.releases.previous === releasing.__rerum.releases.previous){ // If the descendant's previous matches the node I am releasing's @@ -387,7 +405,7 @@ async function healReleasesTree(releasing) { } let origNextArray = releasing.__rerum.releases.next for (const a of ancestors){ - let safe_ancestor = JSON.parse(JSON.stringify(a)) + let safe_ancestor = structuredClone(a) let a_id = safe_ancestor._id let ancestorNextArray = safe_ancestor.__rerum.releases.next if (ancestorNextArray.length == 0) { @@ -447,6 +465,7 @@ async function healReleasesTree(releasing) { export { _contextid, idNegotiation, + getPagination, generateSlugId, index, ObjectID, diff --git a/database/__mocks__/index.js b/database/__mocks__/index.js new file mode 100644 index 00000000..39204ed4 --- /dev/null +++ b/database/__mocks__/index.js @@ -0,0 +1,45 @@ +/** + * Jest mock for the database/index.js module. + * Replaces all MongoDB operations with jest.fn() stubs so tests + * can run without a live database connection. + * + * Defaults (can be overridden per-test with mockResolvedValueOnce / mockReturnValueOnce): + * db.findOne → resolves null + * db.find → returns a chainable cursor whose toArray resolves [] + * db.insertOne → resolves { insertedId: 'testid123' } + * db.replaceOne→ resolves { modifiedCount: 1 } + * db.bulkWrite → resolves { result: { insertedIds: [] }, insertedCount: 0 } + * db.deleteOne → resolves { deletedCount: 1 } + * newID → returns 'testid123' + * isValidID → returns false (forces ObjectID() path in controllers) + * connected → resolves true + * + * @author thehabes + */ + +import { jest } from '@jest/globals' + +/** Chainable cursor stub returned by db.find() */ +const mockCursor = { + limit: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([]) +} + +export const db = { + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockReturnValue(mockCursor), + insertOne: jest.fn().mockResolvedValue({ insertedId: 'testid123' }), + replaceOne: jest.fn().mockResolvedValue({ modifiedCount: 1 }), + countDocuments: jest.fn().mockResolvedValue(0), + bulkWrite: jest.fn().mockResolvedValue({ + result: { insertedIds: [{ _id: 'bulkid1' }, { _id: 'bulkid2' }] }, + insertedIds: {}, + insertedCount: 0 + }), + deleteOne: jest.fn().mockResolvedValue({ deletedCount: 1 }) +} + +export const newID = jest.fn().mockReturnValue('testid123') +export const isValidID = jest.fn().mockReturnValue(false) +export const connected = jest.fn().mockResolvedValue(true) diff --git a/jest.config.js b/jest.config.js index c5a4eb46..c9a7e458 100644 --- a/jest.config.js +++ b/jest.config.js @@ -20,6 +20,14 @@ const config = { //That is OK in the testing scenario. In production, only one connection is made and it is closed when the app exits. detectOpenHandles : false, + // Automatically clear mock call history before every test (preserves default implementations) + clearMocks: true, + + // Redirect all database/index.js imports to the mock so tests never need a live DB + moduleNameMapper: { + '^.+/database/index\\.js$': '/database/__mocks__/index.js' + }, + displayName: { name: 'RERUM v1', color: 'cyan', @@ -211,4 +219,4 @@ const config = { // watchman: true, } -export default config \ No newline at end of file +export default config diff --git a/package-lock.json b/package-lock.json index 1e9a5839..b6f164ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,8 +25,8 @@ "supertest": "^7.2.2" }, "engines": { - "node": ">=24.12.0", - "npm": ">=11.7.0" + "node": ">=24.14.0", + "npm": ">=11.0.0" } }, "node_modules/@babel/code-frame": { @@ -1335,9 +1335,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1352,9 +1349,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1369,9 +1363,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1386,9 +1377,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1403,9 +1391,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1420,9 +1405,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1437,9 +1419,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1454,9 +1433,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/package.json b/package.json index 39b9f182..b054eda2 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ }, "scripts": { "start": "node ./bin/rerum_v1.js", - "test": "jest", + "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "runtest": "node --experimental-vm-modules node_modules/jest/bin/jest.js" }, "dependencies": { diff --git a/routes/__tests__/bulkCreate.test.js b/routes/__tests__/bulkCreate.test.js index 05dbf29f..eec9ef89 100644 --- a/routes/__tests__/bulkCreate.test.js +++ b/routes/__tests__/bulkCreate.test.js @@ -14,22 +14,39 @@ const addAuth = (req, res, next) => { const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) +process.env.RERUM_ID_PREFIX ??= "https://store.rerum.io/v1/id/" + // Mount our own /bulkCreate route without auth that will use controller.bulkCreate routeTester.use("/bulkCreate", [addAuth, controller.bulkCreate]) +const MOCK_PREFIX = process.env.RERUM_ID_PREFIX ?? "https://store.rerum.io/v1/id/" + +import { db } from '../../database/index.js' + it("'/bulkCreate' route functions", async () => { + // bulkCreate expects dbResponse.result.insertedIds as an array of objects with _id + db.bulkWrite.mockResolvedValueOnce({ + result: { insertedIds: [{ _id: 'id1' }, { _id: 'id2' }] }, + insertedIds: { 0: 'id1', 1: 'id2' }, + insertedCount: 2 + }) + const response = await request(routeTester) - .post("/bulkCreate") - .send([{ "test": "item1" }, { "test": "item2" }]) - .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.header.location).toBe(response.body["@id"]) + .post('/bulkCreate') + .set('Content-Type', 'application/json') + .send([ + { test: 'data-1' }, + { test: 'data-2' } + ]) + expect(response.statusCode).toBe(201) expect(Array.isArray(response.body)).toBe(true) - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() + expect(response.body.length).toBe(2) + expect(response.body[0]._id).toBeUndefined() + expect(response.body[1]._id).toBeUndefined() + + const linkHeader = response.headers['link'] + expect(linkHeader).toBeDefined() + expect(String(linkHeader)).toContain(`${MOCK_PREFIX}id1`) + expect(String(linkHeader)).toContain(`${MOCK_PREFIX}id2`) }) diff --git a/routes/__tests__/create.test.js b/routes/__tests__/create.test.js index 3c922587..22171dcf 100644 --- a/routes/__tests__/create.test.js +++ b/routes/__tests__/create.test.js @@ -19,25 +19,22 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] routeTester.use("/create", [addAuth, controller.create]) it("'/create' route functions", async () => { + // insertOne mock default resolves { insertedId: 'testid123' } + // newID mock returns 'testid123', so @id = RERUM_ID_PREFIX + 'testid123' const response = await request(routeTester) .post("/create") - .send({ "test": "item" }) .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.header.location).toBe(response.body["@id"]) + .send({ test: "item" }) expect(response.statusCode).toBe(201) - expect(response.body.test).toBe("item") - expect(response.body).toHaveProperty("__rerum") + expect(response.body["@id"] ?? response.body.id).toBeTruthy() expect(response.body._id).toBeUndefined() - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() - + expect(response.body.__rerum).toBeDefined() + expect(response.body.test).toBe("item") + // location header should match the returned @id / id + const returnedId = response.body["@id"] ?? response.body.id + expect(response.headers["location"]).toBe(returnedId) }) it.skip("Support setting valid '_id' on '/create' request body.", async () => { // TODO -}) \ No newline at end of file +}) diff --git a/routes/__tests__/delete.test.js b/routes/__tests__/delete.test.js index 964655a3..7d98b458 100644 --- a/routes/__tests__/delete.test.js +++ b/routes/__tests__/delete.test.js @@ -23,17 +23,37 @@ routeTester.use("/create", [addAuth, controller.create]) // Mount our own /delete route without auth that will use controller.delete routeTester.use("/delete/:_id", [addAuth, controller.deleteObj]) +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = process.env.RERUM_ID_PREFIX ?? "https://store.rerum.io/v1/id/" +const MOCK_ID = "11111" + +const mockDoc = { + _id: MOCK_ID, + "@id": `${MOCK_PREFIX}${MOCK_ID}`, + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +import { db } from '../../database/index.js' + it("'/delete' route functions", async () => { - const created = await request(routeTester) + // create step (primarily validates route wiring) + const createResponse = await request(routeTester) .post("/create") - .send({ "test": "item"}) .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - - const response = await request(routeTester) - .delete(`/delete/${created.body["@id"].split("/").pop()}`) - .then(resp => resp) - .catch(err => err) - expect(response.statusCode).toBe(204) + .send({ test: "item" }) + expect(createResponse.statusCode).toBe(201) + + // delete step uses findOne + replaceOne internally + db.findOne.mockResolvedValueOnce(mockDoc) + const deleteResponse = await request(routeTester).delete(`/delete/${MOCK_ID}`) + // deleteObj returns 204 No Content on success + expect(deleteResponse.statusCode).toBe(204) }) diff --git a/routes/__tests__/history.test.js b/routes/__tests__/history.test.js index ffcff3e3..42bab868 100644 --- a/routes/__tests__/history.test.js +++ b/routes/__tests__/history.test.js @@ -1,5 +1,4 @@ import { jest } from "@jest/globals" -jest.setTimeout(10000) // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" @@ -12,19 +11,31 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] // Mount our own /history route without auth that will use controller.history routeTester.use("/history/:_id", controller.history) -it("'/history/:id' route functions", async () => { +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = "https://store.rerum.io/v1/id/" +const MOCK_ID = "testid123" + +const mockDoc = { + _id: MOCK_ID, + "@id": `${MOCK_PREFIX}${MOCK_ID}`, + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} - const response = await request(routeTester) - .get("/history/11111") - .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.statusCode).toBe(200) - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() - expect(Array.isArray(response.body)).toBe(true) +import { db } from '../../database/index.js' + +it("'/history/:id' route functions", async () => { + // history: findOne returns the root object; getAllVersions calls db.find().toArray() → [] + // getAllAncestors on a root object returns [] → response body is [] + db.findOne.mockResolvedValueOnce(mockDoc) + const response = await request(routeTester).get(`/history/${MOCK_ID}`) + expect(response.statusCode).toBe(200) + expect(Array.isArray(response.body)).toBe(true) }) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index b5957744..d171d195 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -11,24 +11,35 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] // Mount our own /id route without auth that will use controller.id routeTester.use("/id/:_id", controller.id) +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = "https://store.rerum.io/v1/id/" +const MOCK_ID = "testid123" + +const mockDoc = { + _id: MOCK_ID, + "@id": `${MOCK_PREFIX}${MOCK_ID}`, + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +// Import db mock so we can configure per-test behaviour +import { db } from '../../database/index.js' + it("'/id/:id' route functions", async () => { - const response = await request(routeTester) - .get("/id/11111") - .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.body["@id"].split("/").pop()).toBe("11111") - expect(response.body._id).toBeUndefined() + db.findOne.mockResolvedValueOnce(mockDoc) + const response = await request(routeTester).get(`/id/${MOCK_ID}`) expect(response.statusCode).toBe(200) - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["cache-control"]).toBeTruthy() - expect(response.headers["last-modified"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() - expect(response.headers["location"]).toBeTruthy() + // idNegotiation strips _id; @id present (or id for LD contexts) + expect(response.body["@id"] ?? response.body.id).toBeTruthy() + expect(response.body._id).toBeUndefined() + expect(response.body.__rerum).toBeDefined() }) it.skip("Proper '@id-id' negotation on GET by URI.", async () => { diff --git a/routes/__tests__/patch.test.js b/routes/__tests__/patch.test.js index 319ef743..b0651235 100644 --- a/routes/__tests__/patch.test.js +++ b/routes/__tests__/patch.test.js @@ -19,21 +19,37 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] routeTester.use("/patch", [addAuth, controller.patchUpdate]) const unique = new Date(Date.now()).toISOString().replace("Z", "") +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = process.env.RERUM_ID_PREFIX ?? "https://store.rerum.io/v1/id/" +const MOCK_ORIG_ID = "11111" + +const mockDoc = { + _id: MOCK_ORIG_ID, + "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, + "RERUM Update Test": "oldValue", + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +import { db } from '../../database/index.js' + it("'/patch' route functions", async () => { + // patchUpdate: findOne → original (has "RERUM Update Test"), patch it, insertOne + replaceOne + db.findOne.mockResolvedValueOnce(mockDoc) const response = await request(routeTester) - .patch('/patch') - .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "RERUM Update Test":unique}) + .patch("/patch") .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.header.location).toBe(response.body["@id"]) + .send({ "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, "RERUM Update Test": unique }) expect(response.statusCode).toBe(200) + const returnedId = response.body["@id"] ?? response.body.id + expect(returnedId).toBeTruthy() + expect(response.headers["location"]).toBe(returnedId) expect(response.body._id).toBeUndefined() - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() - }) diff --git a/routes/__tests__/query.test.js b/routes/__tests__/query.test.js index 8b494836..acace143 100644 --- a/routes/__tests__/query.test.js +++ b/routes/__tests__/query.test.js @@ -11,24 +11,43 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] // Mount our own /query route without auth that will use controller.query routeTester.use("/query", controller.query) +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = "https://store.rerum.io/v1/id/" +const MOCK_ID = "testid123" + +const mockDoc = { + _id: MOCK_ID, + "@id": `${MOCK_PREFIX}${MOCK_ID}`, + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +import { db } from '../../database/index.js' + it("'/query' route functions", async () => { + // Override the find cursor for this test to return one result + const queryCursor = { + limit: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([mockDoc]) + } + db.find.mockReturnValueOnce(queryCursor) const response = await request(routeTester) .post("/query") - .send({ "_id": "11111" }) .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.statusCode).toBe(200) - expect(Array.isArray(response.body)).toBe(true) - expect(response.body.length).toBeTruthy() - expect(response.body[0]._id).toBeUndefined() - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() - + .send({ test: "item" }) + expect(response.statusCode).toBe(200) + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBeGreaterThan(0) + expect(response.body[0]["@id"]).toBeTruthy() + expect(response.body[0]._id).toBeUndefined() }) it.skip("Proper '@id-id' negotation on objects returned from '/query'.", async () => { diff --git a/routes/__tests__/release.test.js b/routes/__tests__/release.test.js index 477b2b6a..d9664d85 100644 --- a/routes/__tests__/release.test.js +++ b/routes/__tests__/release.test.js @@ -21,24 +21,53 @@ routeTester.use("/create", [addAuth, controller.create]) routeTester.use("/release/:_id", [addAuth, controller.release]) const slug = `rcgslu${new Date(Date.now()).toISOString().replace("Z", "")}` -it("'/release' route functions", async () => { +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = process.env.RERUM_ID_PREFIX ?? "https://store.rerum.io/v1/id/" +const MOCK_ID = "11111" + +const mockDoc = { + _id: MOCK_ID, + "@id": `${MOCK_PREFIX}${MOCK_ID}`, + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} - const created = await request(routeTester) +import { db } from '../../database/index.js' + +it("'/release' route functions", async () => { + // create something to release + const createResponse = await request(routeTester) .post("/create") - .send({ "test": "item" }) .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - - const slug = `rcgslu${new Date(Date.now()).toISOString().replace("Z", "")}` - - const response = await request(routeTester) - .patch(`/release/${created.body["@id"].split("/").pop()}`) - .set('Slug', slug) - .then(resp => resp) - .catch(err => err) - expect(response.statusCode).toBe(200) - expect(response.body.__rerum.isReleased).toBeTruthy() - expect(response.body.__rerum.slug).toBe(slug) - controller.remove(slug) + .send({ test: "item" }) + expect(createResponse.statusCode).toBe(201) + + // release with slug: + // 1st findOne for slug uniqueness check -> null + // 2nd findOne to fetch object being released -> mockDoc + db.findOne + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockDoc) + + const releaseResponse = await request(routeTester) + .post(`/release/${MOCK_ID}`) + .set("Slug", slug) + .set("Content-Type", "application/json") + + expect(releaseResponse.statusCode).toBe(200) + expect(releaseResponse.body._id).toBeUndefined() + expect(releaseResponse.body.__rerum).toBeDefined() + expect(releaseResponse.body.__rerum.isReleased).toBeTruthy() + const returnedId = releaseResponse.body["@id"] ?? releaseResponse.body.id + expect(releaseResponse.headers["location"]).toBe(returnedId) + + // cleanup slug object via internal helper path + await controller.remove(slug) }) diff --git a/routes/__tests__/set.test.js b/routes/__tests__/set.test.js index 5c4af116..2276c149 100644 --- a/routes/__tests__/set.test.js +++ b/routes/__tests__/set.test.js @@ -20,22 +20,35 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] routeTester.use("/set", [addAuth, controller.patchSet]) const unique = new Date(Date.now()).toISOString().replace("Z", "") +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = process.env.RERUM_ID_PREFIX ?? "https://store.rerum.io/v1/id/" +const MOCK_ORIG_ID = "11111" + +const mockDoc = { + _id: MOCK_ORIG_ID, + "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +import { db } from '../../database/index.js' + it("'/set' route functions", async () => { + db.findOne.mockResolvedValueOnce(mockDoc) const response = await request(routeTester) .patch("/set") - .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "test_set":unique}) - .set('Content-Type', 'application/json; charset=utf-8') - .then(resp => resp) - .catch(err => err) - expect(response.header.location).toBe(response.body["@id"]) - expect(response.headers["location"]).not.toBe(`${process.env.RERUM_ID_PREFIX}11111`) - expect(response.statusCode).toBe(200) - expect(response.body._id).toBeUndefined() - expect(response.body["test_set"]).toBe(unique) - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() + .set("Content-Type", "application/json") + .send({ "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, test_set: unique }) + expect(response.statusCode).toBe(200) + expect(response.body["test_set"]).toBe(unique) + expect(response.body._id).toBeUndefined() + const returnedId = response.body["@id"] ?? response.body.id + expect(response.headers["location"]).toBe(returnedId) }) diff --git a/routes/__tests__/since.test.js b/routes/__tests__/since.test.js index c8b59213..12d433f4 100644 --- a/routes/__tests__/since.test.js +++ b/routes/__tests__/since.test.js @@ -1,5 +1,4 @@ import { jest } from "@jest/globals" -jest.setTimeout(10000) // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" @@ -12,18 +11,31 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] // Mount our own /create route without auth that will use controller.history routeTester.use("/since/:_id", controller.since) +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = "https://store.rerum.io/v1/id/" +const MOCK_ID = "testid123" + +const mockDoc = { + _id: MOCK_ID, + "@id": `${MOCK_PREFIX}${MOCK_ID}`, + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +import { db } from '../../database/index.js' + it("'/since/:id' route functions", async () => { - const response = await request(routeTester) - .get("/since/11111") - .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.statusCode).toBe(200) - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() - expect(Array.isArray(response.body)).toBe(true) -}) \ No newline at end of file + // since: findOne returns the root object; getAllVersions calls db.find().toArray() → [] + // getAllDescendants on object with next:[] returns [] → response body is [] + db.findOne.mockResolvedValueOnce(mockDoc) + const response = await request(routeTester).get(`/since/${MOCK_ID}`) + expect(response.statusCode).toBe(200) + expect(Array.isArray(response.body)).toBe(true) +}) diff --git a/routes/__tests__/unset.test.js b/routes/__tests__/unset.test.js index 456da795..fe7baa56 100644 --- a/routes/__tests__/unset.test.js +++ b/routes/__tests__/unset.test.js @@ -19,22 +19,38 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] // Mount our own /create route without auth that will use controller.create routeTester.use("/unset", [addAuth, controller.patchUnset]) +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = process.env.RERUM_ID_PREFIX ?? "https://store.rerum.io/v1/id/" +const MOCK_ORIG_ID = "11111" + +const mockDoc = { + _id: MOCK_ORIG_ID, + "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, + test_obj: "to remove", + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +import { db } from '../../database/index.js' + it("'/unset' route functions", async () => { + db.findOne.mockResolvedValueOnce(mockDoc) const response = await request(routeTester) .patch("/unset") - .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "test_obj":null}) - .set('Content-Type', 'application/json; charset=utf-8') - .then(resp => resp) - .catch(err => err) - expect(response.header.location).toBe(response.body["@id"]) - expect(response.statusCode).toBe(200) - expect(response.body._id).toBeUndefined() - expect(response.body.hasOwnProperty("test_obj")).toBe(false) - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() + .set("Content-Type", "application/json") + .send({ "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, test_obj: null }) + expect(response.statusCode).toBe(200) + expect(response.body["test_obj"]).toBeUndefined() + expect(response.body._id).toBeUndefined() + const returnedId = response.body["@id"] ?? response.body.id + expect(response.headers["location"]).toBe(returnedId) }) + diff --git a/routes/__tests__/update.test.js b/routes/__tests__/update.test.js index 67ae5318..ca0524a3 100644 --- a/routes/__tests__/update.test.js +++ b/routes/__tests__/update.test.js @@ -19,24 +19,39 @@ routeTester.use(express.json({ type: ["application/json", "application/ld+json"] routeTester.use("/update", [addAuth, controller.putUpdate]) const unique = new Date(Date.now()).toISOString().replace("Z", "") -it("'/update' route functions", async () => { +const MOCK_AGENT = "https://store.rerum.io/v1/id/agent007" +const MOCK_PREFIX = process.env.RERUM_ID_PREFIX ?? "https://store.rerum.io/v1/id/" +const MOCK_ORIG_ID = "11111" + +const mockDoc = { + _id: MOCK_ORIG_ID, + "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, + "RERUM Update Test": "oldValue", + test: "item", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} +import { db } from '../../database/index.js' + +it("'/update' route functions", async () => { + // putUpdate: findOne → original, insertOne → new version, replaceOne → update original's next + db.findOne.mockResolvedValueOnce(mockDoc) const response = await request(routeTester) - .put('/update') - .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "RERUM Update Test":unique}) + .put("/update") .set("Content-Type", "application/json") - .then(resp => resp) - .catch(err => err) - expect(response.header.location).toBe(response.body["@id"]) - expect(response.headers["location"]).not.toBe(`${process.env.RERUM_ID_PREFIX}11111`) + .send({ "@id": `${MOCK_PREFIX}${MOCK_ORIG_ID}`, "RERUM Update Test": unique }) expect(response.statusCode).toBe(200) + const returnedId = response.body["@id"] ?? response.body.id + expect(returnedId).toBeTruthy() + expect(response.headers["location"]).toBe(returnedId) + expect(response.headers["location"]).not.toBe(`${MOCK_PREFIX}${MOCK_ORIG_ID}`) expect(response.body._id).toBeUndefined() expect(response.body["RERUM Update Test"]).toBe(unique) - expect(response.headers["content-length"]).toBeTruthy() - expect(response.headers["content-type"]).toBeTruthy() - expect(response.headers["date"]).toBeTruthy() - expect(response.headers["etag"]).toBeTruthy() - expect(response.headers["allow"]).toBeTruthy() - expect(response.headers["link"]).toBeTruthy() - }) diff --git a/utils.js b/utils.js index e007dc72..7ee4cd7d 100644 --- a/utils.js +++ b/utils.js @@ -25,9 +25,10 @@ isReleased —always "" * @param update A trigger for special handling from update actions * @return configuredObject The same object that was recieved but with the proper __rerum options. This object is intended to be saved as a new object (@see versioning) */ + const configureRerumOptions = function(generator, received, update, extUpdate){ - let configuredObject = JSON.parse(JSON.stringify(received)) - let received_options = received.__rerum ? JSON.parse(JSON.stringify(received.__rerum)) : {} + let configuredObject = structuredClone(received) + let received_options = received.__rerum ? structuredClone(received.__rerum) : {} let history = {} let releases = {} let rerumOptions = {}