diff --git a/__tests__/core_provider_contract.test.js b/__tests__/core_provider_contract.test.js index c73d1309..0ebe3c40 100644 --- a/__tests__/core_provider_contract.test.js +++ b/__tests__/core_provider_contract.test.js @@ -199,11 +199,17 @@ const requiredResponseCodes = { // 409 is reachable via slug conflict (utils.createExpressError maps code 11000 → 409). 'PATCH /api/release/{id}': ['200', '400', '401', '403', '404', '409'], 'GET /id/{id}': ['200', '404'], + // 200/400/404 are asserted in routes/__tests__/id.test.js. 413 comes from the global 5mb + // express.json limit in app.js and 415 from rest.verifyJsonContentType, both covered + // generically in routes/__tests__/{routes_mounted,contentType}.test.js as for the /api routes. + 'GET /id/{id}/expanded': ['200', '404'], + 'POST /id/{id}/expanded': ['200', '400', '404', '413', '415'], 'GET /since/{id}': ['200', '404'], 'GET /history/{id}': ['200', '404'], // HEAD parity tests in routes/__tests__/{id,since,history,query}.test.js assert 404 on miss; // enforce that the contract declares the same so drift on either side is caught. 'HEAD /id/{id}': ['200', '404'], + 'HEAD /id/{id}/expanded': ['200', '404'], 'HEAD /since/{id}': ['200', '404'], 'HEAD /history/{id}': ['200', '404'], 'HEAD /api/query': ['200', '404'], diff --git a/__tests__/routes_mounted.test.js b/__tests__/routes_mounted.test.js index aacb2094..abc16814 100644 --- a/__tests__/routes_mounted.test.js +++ b/__tests__/routes_mounted.test.js @@ -26,7 +26,8 @@ const mountedApiRoutes = [ { name: '/v1/api/delete/{id}', method: 'delete', path: '/v1/api/delete/test-mounted-id' }, { name: '/v1/api/release/{id}', method: 'patch', path: '/v1/api/release/test-mounted-id' }, { name: '/v1/api/search', method: 'post', path: '/v1/api/search', headers: { 'Content-Type': 'text/plain' }, body: 'mounted search' }, - { name: '/v1/api/search/phrase', method: 'post', path: '/v1/api/search/phrase', headers: { 'Content-Type': 'text/plain' }, body: 'mounted phrase search' } + { name: '/v1/api/search/phrase', method: 'post', path: '/v1/api/search/phrase', headers: { 'Content-Type': 'text/plain' }, body: 'mounted phrase search' }, + { name: '/v1/id/{_id}/expanded', method: 'put', path: '/v1/id/test-mounted-id/expanded' } ] describe('Mounted route surface', () => { diff --git a/__tests__/utils.test.js b/__tests__/utils.test.js index 900ca7c3..8ec29a8f 100644 --- a/__tests__/utils.test.js +++ b/__tests__/utils.test.js @@ -9,9 +9,10 @@ import { parseDocumentID, _contextid, idNegotiation, - getPagination + getPagination, + findLeafAnnotationsFor } from '../controllers/utils.js' -import { db, resetMocks } from '../database/index.js' +import { db, resetMocks, createCursor } from '../database/index.js' describe('utils.js auth gates', () => { it('isDeleted returns true only for objects with __deleted', () => { @@ -49,6 +50,63 @@ describe('utils.js configureRerumOptions', () => { ) assert.strictEqual(result.__rerum.generatedBy, 'https://store.rerum.io/v1/id/legitimate-agent') }) + + const AGENT = 'https://store.rerum.io/v1/id/legitimate-agent' + const RECEIVED_ID = 'https://store.rerum.io/v1/id/received-id' + const FORGED = { + generatedBy: 'https://attacker.example/forged', + isReleased: '2020-01-01T00:00:00.000', + isOverwritten: '2020-01-01T00:00:00.000', + history: { prime: 'https://store.rerum.io/v1/id/forged-prime', previous: 'https://store.rerum.io/v1/id/forged-previous', next: ['https://store.rerum.io/v1/id/forged-next'] }, + releases: { previous: 'https://store.rerum.io/v1/id/forged-release', next: [], replaces: '' } + } + + it('ignores a client-supplied __rerum when minting a new object', () => { + const created = utils.configureRerumOptions(AGENT, { '@id': RECEIVED_ID, __rerum: structuredClone(FORGED) }, false, false) + assert.strictEqual(created.__rerum.history.prime, 'root') + assert.strictEqual(created.__rerum.history.previous, '') + assert.deepStrictEqual(created.__rerum.history.next, []) + assert.strictEqual(created.__rerum.releases.previous, '') + // isReleased gates release and overwrite, isOverwritten is the optimistic locking token. + assert.strictEqual(created.__rerum.generatedBy, AGENT, 'attribution cannot be forged') + assert.strictEqual(created.__rerum.isReleased, '', 'a client cannot mint a pre-released object') + assert.strictEqual(created.__rerum.isOverwritten, '', 'a client cannot mint a locking token') + + // An external object imported through an update is also a root, but it remembers its external self. + const imported = utils.configureRerumOptions(AGENT, { '@id': 'https://elsewhere.example.org/thing', __rerum: structuredClone(FORGED) }, false, true) + assert.strictEqual(imported.__rerum.history.prime, 'root') + assert.strictEqual(imported.__rerum.history.previous, 'https://elsewhere.example.org/thing') + assert.strictEqual(imported.__rerum.releases.previous, '') + }) + + it('carries the version and release chain forward when updating an existing object', () => { + const fromRoot = utils.configureRerumOptions( + AGENT, + { '@id': RECEIVED_ID, __rerum: { history: { prime: 'root', previous: '', next: [] } } }, + true, + false + ) + assert.strictEqual(fromRoot.__rerum.history.prime, RECEIVED_ID, 'the root object cannot pass "root" on as the prime') + assert.strictEqual(fromRoot.__rerum.history.previous, RECEIVED_ID) + + const PRIME = 'https://store.rerum.io/v1/id/prime-id' + const RELEASE = 'https://store.rerum.io/v1/id/released-id' + const fromDescendant = utils.configureRerumOptions( + AGENT, + { + '@id': RECEIVED_ID, + __rerum: { + history: { prime: PRIME, previous: 'https://store.rerum.io/v1/id/older-id', next: [] }, + releases: { previous: RELEASE, next: [], replaces: '' } + } + }, + true, + false + ) + assert.strictEqual(fromDescendant.__rerum.history.prime, PRIME, 'an object that knows its prime passes it on') + assert.strictEqual(fromDescendant.__rerum.history.previous, RECEIVED_ID) + assert.strictEqual(fromDescendant.__rerum.releases.previous, RELEASE) + }) }) describe('controllers/utils.js generateSlugId', () => { @@ -212,6 +270,12 @@ describe('controllers/utils.js _contextid', () => { _contextid(['http://example.com/other', 'http://iiif.io/api/presentation/3/context.json']), true ) + // An inline term definition object, or any other non-string member, names no context. + assert.strictEqual( + _contextid([{ '@vocab': 'http://example.org/terms#' }, 'http://www.w3.org/ns/anno.jsonld']), + true + ) + assert.strictEqual(_contextid([{ '@vocab': 'http://example.org/terms#' }]), false) }) it('returns false for non-string, non-array input', () => { @@ -288,3 +352,73 @@ describe('controllers/utils.js getPagination', () => { assert.ok(result.limit < huge, `limit should be clamped below ${huge}`) }) }) + +describe('controllers/utils.js findLeafAnnotationsFor', () => { + const ENTITY_URI = 'https://store.rerum.io/v1/id/entity-id' + const SLUG_URI = 'https://store.rerum.io/v1/id/entity-slug' + const TARGET_KEYS = ['target', 'target.@id', 'target.id', 'target.source', 'target.source.@id', 'target.source.id'] + + let capturedQuery + + /** + * Point db.find() at a cursor over the given documents and record the filter it was called with. + * + * @param docs The Annotation documents the cursor will yield. + */ + function armFind(docs = []) { + resetMocks() + capturedQuery = undefined + db.find.mockImplementationOnce(query => { + capturedQuery = query + return createCursor(docs) + }) + } + + // $and[0] holds the target conditions, $and[1] the Annotation type conditions. + const targetConditions = () => capturedQuery.$and[0].$or + + it('constrains the query to the leaf versions, every target key, and every type spelling', async () => { + armFind() + + await findLeafAnnotationsFor([ENTITY_URI, SLUG_URI, ENTITY_URI, undefined, '']) + + assert.deepStrictEqual(capturedQuery['__rerum.history.next'], { $exists: true, $size: 0 }) + for (const targetKey of TARGET_KEYS) { + const values = targetConditions() + .filter(condition => Object.hasOwn(condition, targetKey)) + .map(condition => condition[targetKey]) + assert.strictEqual(values.length, 8, `${targetKey}: two URIs, each in two schemes and as a fragment`) + assert.ok(values.includes(ENTITY_URI) && values.includes(ENTITY_URI.replace(/^https/, 'http'))) + assert.ok(values.includes(SLUG_URI), 'every URI the entity answers to is targeted') + const patterns = values.filter(value => value instanceof RegExp) + assert.ok(patterns.some(pattern => pattern.test(`${ENTITY_URI}#xywh=0,0,100,100`)), 'a fragment of the URI is a match') + assert.ok( + patterns.every(pattern => !pattern.test('https://storeXrerum.io/v1/id/entity-id#xywh=0,0,100,100')), + 'the URI is escaped, so its dots are not wildcards' + ) + } + assert.deepStrictEqual(capturedQuery.$and[1].$or, [ + { type: 'Annotation' }, + { type: 'oa:Annotation' }, + { type: 'http://www.w3.org/ns/oa#Annotation' }, + { type: 'https://www.w3.org/ns/oa#Annotation' }, + { '@type': 'Annotation' }, + { '@type': 'oa:Annotation' }, + { '@type': 'http://www.w3.org/ns/oa#Annotation' }, + { '@type': 'https://www.w3.org/ns/oa#Annotation' } + ]) + + armFind() + await findLeafAnnotationsFor('bare-slug') + assert.strictEqual(targetConditions().length, TARGET_KEYS.length, 'a non-URI target has no scheme or fragment to anticipate') + }) + + it('gathers nothing rather than querying on an empty $or when there is no target', async () => { + // An empty '$or' is a MongoDB error, not an empty result, so the query is never sent. + armFind([{ _id: 'anno001', type: 'Annotation' }]) + + assert.deepStrictEqual(await findLeafAnnotationsFor([undefined, '', null]), []) + assert.deepStrictEqual(await findLeafAnnotationsFor(undefined), []) + assert.strictEqual(capturedQuery, undefined, 'nothing to target is nothing to gather') + }) +}) diff --git a/routes/__tests__/id.test.js b/routes/__tests__/id.test.js index e6701fc6..5d9f9773 100644 --- a/routes/__tests__/id.test.js +++ b/routes/__tests__/id.test.js @@ -9,6 +9,8 @@ import controller from '../../db-controller.js' const routeTester = new express() routeTester.use(express.json({ type: ["application/json", "application/ld+json"] })) +routeTester.use("/id/:_id/expanded", controller.idExpanded) + // Mount our own /id route without auth, matching routes/id.js: GET only, no HEAD handler. routeTester.use("/id/:_id", controller.id) @@ -31,10 +33,12 @@ const mockDoc = { } // Import db mock so we can configure per-test behaviour -import { db, resetMocks } from '../../database/index.js' +import { db, resetMocks, createCursor } from '../../database/index.js' beforeEach(() => { resetMocks() + // Restart the anno() sequence below, so each test's merge order is its own call order. + annoCount = 0 }) it("'/id/:id' route functions", async () => { @@ -121,3 +125,311 @@ describe('id route overwrite headers', () => { assert.strictEqual(response.headers['current-overwritten-version'], '') }) }) + +// Fixtures for GET|POST /id/:_id/expanded. +const EXPAND_ID = "expandme123" +const EXPAND_URI = `${MOCK_PREFIX}${EXPAND_ID}` +const EVIL_URI = "https://evil.example.org/hijacked" + +const expandableDoc = { + _id: EXPAND_ID, + "@id": EXPAND_URI, + "@context": "http://www.loc.gov/mods", + "@type": "named-gloss", + title: "A Gloss", + __rerum: { + generatedBy: MOCK_AGENT, + history: { prime: "root", previous: "", next: [] }, + isReleased: "", + isOverwritten: "", + releases: { previous: "", next: [], replaces: "" }, + createdAt: "2025-01-01T00:00:00.000" + } +} + +let annoCount = 0 + +/** + * Build a leaf Annotation targeting the expandable record. Each one needs its own '_id' because + * findLeafAnnotationsFor() sorts on it before dropping it, which fixes the merge order. + * + * @param props The Annotation properties under test, usually a body. + * @return An Annotation document. + */ +function anno(props) { + annoCount++ + return { + _id: `anno${String(annoCount).padStart(3, "0")}`, + type: "Annotation", + target: EXPAND_URI, + ...props + } +} + +// The MongoDB filter the controller built for the last expansion, or undefined when it never queried. +let capturedQuery + +/** + * Arm the database double for a single /expanded request. + * + * @param record The document db.findOne() will answer with. + * @param annos The Annotation documents the cursor will yield. + */ +function armExpansion(record, annos = []) { + capturedQuery = undefined + db.findOne.mockResolvedValueOnce(structuredClone(record)) + db.find.mockImplementationOnce(query => { + capturedQuery = query + return createCursor(annos) + }) +} + +describe('GET /id/:id/expanded', () => { + it("'/id/:id/expanded' route functions", async () => { + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(response.body["@id"], EXPAND_URI) + assert.strictEqual(response.body.subject, "history") + assert.strictEqual(response.body._id, undefined) + assert.strictEqual(Object.keys(response.body).at(-1), '__rerum', '__rerum stays last') + assert.strictEqual(response.headers['annotations-gathered'], '1') + assert.strictEqual(response.headers['annotations-merged'], '1') + assert.strictEqual(response.headers['cache-control'], 'max-age=86400, must-revalidate') + assert.strictEqual(response.headers['current-overwritten-version'], '') + }) + + it('sends the Web Annotation Link header for a container-typed record', async () => { + // The type may be serialized as a JSON-LD Array, which still names a container. + const container = structuredClone(expandableDoc) + container["@type"] = ["Manifest", "AnnotationPage"] + armExpansion(container, []) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.match(response.headers.link, /anno\.jsonld/) + }) + + it('returns 404 when the object is not in RERUM', async () => { + db.findOne.mockResolvedValueOnce(null) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 404, 'a record that is not in RERUM has no expansion') + }) + + it('merges the anticipated body formats, collecting collisions into an Array', async () => { + const textualBody = { type: "TextualBody", value: "bare spelling", format: "text/plain", language: "en" } + const prefixedBody = { "@type": "oa:TextualBody", value: "oa spelling" } + const arrayTypedBody = { type: ["TextualBody"], value: "Array-typed spelling" } + const gathered = [ + anno({ body: { subject: "history" } }), + anno({ body: [{ era: "medieval" }] }), + anno({ bodyValue: "the W3C shorthand" }), + anno({ body: textualBody }), + anno({ body: prefixedBody }), + anno({ body: [arrayTypedBody] }), + anno({ body: { title: "An Annotated Title" } }), + anno({ body: { colors: ["red", "blue"] } }), + anno({ body: { colors: ["black"] } }) + ] + // The query plan promises no order, so hand them over reversed. The expansion sorts by '_id' + // before merging, which is what makes the assembled entity reproducible. + armExpansion(expandableDoc, [...gathered].reverse()) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.body.subject, "history", 'a single-key body merges as a raw value') + assert.strictEqual(response.body.era, "medieval", 'a one-element Array is the same body unwrapped') + // A TextualBody is kept whole so its format and language survive, whatever the type spelling. + assert.deepStrictEqual(response.body.bodyValue, + ["the W3C shorthand", textualBody, prefixedBody, arrayTypedBody]) + assert.deepStrictEqual(response.body.title, ["A Gloss", "An Annotated Title"], 'the record value comes first') + assert.deepStrictEqual(response.body.colors, ["red", "blue", "black"], 'Array contributions flatten') + }) + + it('does not merge Annotations that make no single assertion', async () => { + armExpansion(expandableDoc, [ + anno({ body: [{ first: "one" }, { second: "two" }] }), + anno({ body: { subject: "history", note: "extra" } }), + anno({ body: "https://store.rerum.io/v1/id/an-external-body" }), + anno({ motivation: "bookmarking" }), + anno({ body: { merged: "yes" } }) + ]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + for (const key of ['first', 'second', 'subject', 'note', 'motivation']) { + assert.strictEqual(response.body[key], undefined, `${key} must not be merged`) + } + assert.strictEqual(response.body.merged, "yes") + assert.strictEqual(response.headers['annotations-gathered'], '5') + assert.strictEqual(response.headers['annotations-merged'], '1', 'only the contributing Annotation counts') + }) + + it('never lets an Annotation body overwrite identity or system properties', async () => { + armExpansion(expandableDoc, [ + anno({ body: { "@id": EVIL_URI } }), + anno({ body: { id: EVIL_URI } }), + anno({ body: { _id: "evil-id" } }), + anno({ body: { __rerum: { evil: true } } }), + anno({ body: { __deleted: { time: "2025-01-01T00:00:00.000" } } }), + anno({ body: { "@context": "https://evil.example.org/context.json" } }), + // An object literal with a __proto__ key sets the prototype instead of creating an own + // property. JSON.parse creates the own property, which is what a MongoDB document has. + anno({ body: JSON.parse('{"__proto__":{"polluted":"yes"}}') }) + ]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.body["@id"], EXPAND_URI) + assert.strictEqual(response.body.id, undefined) + assert.strictEqual(response.body._id, undefined) + assert.strictEqual(response.body.__rerum.evil, undefined) + assert.strictEqual(response.body.__rerum.generatedBy, MOCK_AGENT) + assert.strictEqual(response.body.__deleted, undefined) + assert.strictEqual(response.body["@context"], "http://www.loc.gov/mods") + assert.strictEqual(Object.hasOwn(response.body, '__proto__'), false) + assert.strictEqual({}.polluted, undefined, 'Object.prototype must not be polluted') + assert.strictEqual(response.headers['annotations-merged'], '0') + }) + + it('gathers Annotations that target the record by its Slug', async () => { + const slugged = structuredClone(expandableDoc) + slugged.__rerum.slug = "my-slug" + armExpansion(slugged, []) + + await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + const exactTargets = capturedQuery.$and[0].$or + .filter(condition => typeof condition.target === "string") + .map(condition => condition.target) + assert.ok(exactTargets.includes(EXPAND_URI), 'the _id URI must be targeted') + assert.ok(exactTargets.includes(`${MOCK_PREFIX}my-slug`), 'the slug URI must be targeted too') + }) + + it('returns the tombstone unexpanded for a deleted record', async () => { + const deletedDoc = structuredClone(expandableDoc) + deletedDoc.__deleted = { time: "2025-01-01T00:00:00.000", deletor: MOCK_AGENT } + armExpansion(deletedDoc, [anno({ body: { subject: "history" } })]) + + const response = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 200) + assert.ok(response.body.__deleted) + assert.strictEqual(response.body.subject, undefined) + assert.strictEqual(response.headers['annotations-gathered'], '0') + assert.strictEqual(capturedQuery, undefined, 'a deleted record is not queried for Annotations') + }) + + it('filters the expansion by the generator and creator parameters', async () => { + armExpansion(expandableDoc, []) + + await request(routeTester).get(`/id/${EXPAND_ID}/expanded?generator=${encodeURIComponent(MOCK_AGENT)}&creator=Fred`) + + // $and[0] is the target condition and $and[1] the Annotation type condition. + assert.deepStrictEqual(capturedQuery.$and.slice(2), [ + { + $or: [ + { "__rerum.generatedBy": MOCK_AGENT.replace(/^https/, "http") }, + { "__rerum.generatedBy": MOCK_AGENT } + ] + }, + { creator: "Fred" } + ]) + + armExpansion(expandableDoc, []) + await request(routeTester).get(`/id/${EXPAND_ID}/expanded?generator=one&generator=two&creator=&limit=5`) + assert.deepStrictEqual(capturedQuery.$and.slice(2), [], + 'repeated, empty, and unrelated parameters are not filters') + }) +}) + +describe('POST /id/:id/expanded', () => { + it('reads literal filter keys from the body and ignores the ones the endpoint owns', async () => { + armExpansion(expandableDoc, []) + + const response = await request(routeTester) + .post(`/id/${EXPAND_ID}/expanded?generator=${encodeURIComponent(MOCK_AGENT)}`) + .set('Content-Type', 'application/json') + .send({ + target: EVIL_URI, + "target.id": EVIL_URI, + type: "Dataset", + "@type": "Dataset", + "__rerum.history": { next: [] }, + "__rerum.history.next": { $size: 3 }, + motivation: "describing" + }) + + assert.strictEqual(response.statusCode, 200) + assert.deepStrictEqual(capturedQuery.$and.slice(2), [{ motivation: "describing" }], + 'the reserved keys and the URL parameters are not filters') + assert.strictEqual(response.headers['cache-control'], undefined, + 'a filtered read is not browser-cacheable') + }) + + it('expands unfiltered when no body is supplied', async () => { + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + + // routes/id.js gates POST behind rest.verifyJsonContentType, so a client reaches the + // unfiltered expansion by declaring JSON and sending nothing, not by omitting Content-Type. + const response = await request(routeTester) + .post(`/id/${EXPAND_ID}/expanded`) + .set('Content-Type', 'application/json') + + assert.strictEqual(response.statusCode, 200) + assert.strictEqual(response.body.subject, "history") + assert.deepStrictEqual(capturedQuery.$and.slice(2), []) + }) + + it('returns 400 when the body is not a filter object', async () => { + // Nothing is armed. A rejected body must not reach the record read or the Annotation query. + capturedQuery = undefined + + const response = await request(routeTester) + .post(`/id/${EXPAND_ID}/expanded`) + .set('Content-Type', 'application/json') + .send([{ motivation: "describing" }]) + + assert.strictEqual(response.statusCode, 400) + assert.strictEqual(capturedQuery, undefined, 'a malformed filter body is rejected before any read') + }) +}) + +// RFC 9110 s9.3.2: HEAD sends the same headers a GET would. +describe('HEAD /id/:id/expanded', () => { + it('returns 404 when the object is not in RERUM', async () => { + db.findOne.mockResolvedValueOnce(null) + + const response = await request(routeTester).head(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(response.statusCode, 404) + }) + + it('sends the same headers and Content-Length as the GET, with no body', async () => { + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + const getResp = await request(routeTester).get(`/id/${EXPAND_ID}/expanded`) + + armExpansion(expandableDoc, [anno({ body: { subject: "history" } })]) + const headResp = await request(routeTester).head(`/id/${EXPAND_ID}/expanded`) + + assert.strictEqual(headResp.statusCode, 200) + assert.ok(Number(getResp.headers['content-length']) > 0, 'GET must report a Content-Length') + assert.strictEqual(headResp.headers['content-length'], getResp.headers['content-length']) + assert.ok(headResp.headers['etag'], 'HEAD must report an ETag to validate against') + // Unlike GET /id/:_id, the expansion sends no Last-Modified. It is assembled from the record + // and its Annotations, which have no single modification time, so revalidate on the ETag. + assert.strictEqual(getResp.headers['last-modified'], undefined, + 'an assembled entity has no single modification time to report') + for (const header of ['cache-control', 'etag', 'content-type', 'link', 'allow', + 'current-overwritten-version', 'location', 'annotations-gathered', 'annotations-merged', + 'last-modified']) { + assert.strictEqual(headResp.headers[header], getResp.headers[header], + `HEAD and GET must agree on ${header}`) + } + assert.ok(!headResp.body || Object.keys(headResp.body).length === 0) + }) +}) diff --git a/routes/__tests__/route_wrappers.test.js b/routes/__tests__/route_wrappers.test.js index e4522a83..c0bb1300 100644 --- a/routes/__tests__/route_wrappers.test.js +++ b/routes/__tests__/route_wrappers.test.js @@ -208,6 +208,7 @@ describe('unsupported-method 405 fallbacks', () => { { label: '/delete/:_id', router: deleteRouter, path: '/:_id' }, { label: '/history/:_id', router: historyRouter, path: '/:_id' }, { label: '/id/:_id', router: idRouter, path: '/:_id' }, + { label: '/id/:_id/expanded', router: idRouter, path: '/:_id/expanded' }, { label: '/since/:_id', router: sinceRouter, path: '/:_id' }, { label: '/_gog_fragments_from_manuscript', router: gogFragmentsRouter, path: '/' }, { label: '/_gog_glosses_from_manuscript', router: gogGlossesRouter, path: '/' }