diff --git a/tools/content-node/citation.spec.ts b/tools/content-node/citation.spec.ts index 46d3e30c..a4cfc22b 100644 --- a/tools/content-node/citation.spec.ts +++ b/tools/content-node/citation.spec.ts @@ -285,18 +285,24 @@ describe('StaticCitation', () => { vi.mock('./graph.ts', async () => ({ read: async (cypher, params) => { - if (cypher.includes('DBInfo')) return [{ version: 97 }]; - globalThis.graphAsked = params; - return globalThis.graphRows ?? []; + // The release as the service reads it: releaseNumber, not another property. + if (cypher === 'MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1') + return [{ version: 97 }]; + const shared = globalThis as { graphAsked?: unknown; graphRows?: unknown[] }; + shared.graphAsked = params; + return shared.graphRows ?? []; }, configured: () => true, close: async () => {}, envSetting: (key, fallback = '') => process.env[key] || fallback, })); +/** What the stand-in graph answers, and what it was last asked. */ +const graph = globalThis as { graphAsked?: unknown; graphRows?: unknown[] }; + describe('the citation endpoints', () => { - let base; - let server; + let base: string; + let server: Server; let pmc: Server; let pmcAsked: (string | undefined)[] = []; @@ -343,11 +349,11 @@ describe('the citation endpoints', () => { }); it('/pathway/{id} is {} for an id that is no event, and export an empty 200', async () => { - globalThis.graphRows = []; + graph.graphRows = []; const pathway = await fetch(`${base}/ContentService/citation/pathway/R-HSA-9?dateAccessed=d`); expect([pathway.status, await pathway.text()]).toEqual([200, '{}']); // The id from the path, not the path's own words. - expect(globalThis.graphAsked).toEqual({ id: 'R-HSA-9' }); + expect(graph.graphAsked).toEqual({ id: 'R-HSA-9' }); const exported = await fetch( `${base}/ContentService/citation/export?isPathway=true&id=xyz&ext=bib&dateAccessed=d` ); @@ -358,6 +364,74 @@ describe('the citation endpoints', () => { ]); }); + // An event as graph-core's enhanced query loads it, with nothing related. + const eventRow = (schemaClass: string, labels: string[]) => ({ + root: { + id: 1, + labels: ['DatabaseObject', 'Event', ...labels], + props: { + dbId: 9, + stId: 'R-HSA-9', + displayName: 'An event', + releaseDate: '2020-06-15', + doi: '10.3180/R-HSA-9.1', + schemaClass, + }, + }, + order: [], + nodes: [], + relationships: [], + }); + + it("/pathway/{id} cites the event by the id as given; only a pathway's by its DOI", async () => { + try { + graph.graphRows = [eventRow('Pathway', ['Pathway'])]; + const pathway = await ( + await fetch(`${base}/ContentService/citation/pathway/9?dateAccessed=d`) + ).json(); + // Asked by a versioned stable id, neither its stId nor its dbId: the + // link names the id as given. + graph.graphRows = [eventRow('Reaction', ['ReactionLikeEvent', 'Reaction'])]; + const reaction = await ( + await fetch(`${base}/ContentService/citation/pathway/R-HSA-9.1?dateAccessed=d`) + ).json(); + expect([pathway, reaction]).toEqual([ + { + imageCitation: + 'Image Citation for An event. Reactome, 97, https://doi.org/10.3180/R-HSA-9.1 (d)', + pathwayCitation: + 'The Reactome Consortium (2020). An event. Reactome, 97, https://doi.org/10.3180/R-HSA-9.1 (d)', + }, + { + imageCitation: + 'Image Citation for An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9.1 (d)', + pathwayCitation: + 'The Reactome Consortium (2020). An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9.1 (d)', + }, + ]); + } finally { + graph.graphRows = []; + } + }); + + it('export writes each format as its own type and extension', async () => { + for (const [ext, type, extension] of [ + ['bib', 'application/x-bibtex; charset=utf-8', 'bib'], + ['ris', 'application/x-research-info-systems; charset=utf-8', 'ris'], + ['txt', 'text/plain; charset=utf-8', 'txt'], + ]) { + const response = await fetch( + `${base}/ContentService/citation/export?isPathway=false&id=12345&ext=${ext}&dateAccessed=d` + ); + expect([ + ext, + response.status, + response.headers.get('content-type'), + response.headers.get('content-disposition'), + ]).toEqual([ext, 200, type, `attachment; filename="reactome_citation_12345.${extension}"`]); + } + }); + it('/static/{id} is "unable to fetch" for an answer Java cannot read', async () => { globalThis.pmcAnswer = { resultList: { result: [{ ...europePmc, journalInfo: [] }] } }; try { @@ -474,7 +548,7 @@ describe('the citation endpoints', () => { it("/pathway/{id} is Java's 500 for an event it cannot cite", async () => { // A reaction with no release date: Java's getReleaseDate().substring NPEs. - globalThis.graphRows = [ + graph.graphRows = [ { root: { id: 1, @@ -494,7 +568,7 @@ describe('the citation endpoints', () => { ['Something unexpected happened and the error has been reported.'], ]); } finally { - globalThis.graphRows = []; + graph.graphRows = []; } }); diff --git a/tools/content-node/contained-events.spec.mjs b/tools/content-node/contained-events.spec.mjs index dd014ff5..60acfe3c 100644 --- a/tools/content-node/contained-events.spec.mjs +++ b/tools/content-node/contained-events.spec.mjs @@ -44,8 +44,14 @@ vi.mock('./graph.ts', () => ({ asked.push([cypher, params]); if (cypher.includes('[r:hasEvent*1..100]')) return params.stId === 'R-HSA-100' || params.dbId ? (records ?? RECORDS) : []; - for (const [type, rows] of Object.entries(lazy)) - if (cypher.includes(`[r:${type}]`)) return rows; + // A lazy load by what it asks: `${type} ${direction} ${dbId}`, so one in + // the wrong direction, or of another event, finds nothing. + const load = /\{dbId:\$dbId\}\)(<-|-)\[r:(\w+)\](->|-)/.exec(cypher); + if (load) + return ( + lazy[`${load[2]} ${load[1] === '<-' ? 'INCOMING' : 'OUTGOING'} ${Number(params.dbId)}`] ?? + [] + ); return []; }, configured: () => true, @@ -113,22 +119,24 @@ describe('/data/pathway/{id}/containedEvents/{attributeName}', () => { expect(await response.text()).toBe('[R-HSA-200, R-HSA-300]'); }); - it('loads what findAll did not bring, outgoing relationships too', async () => { - lazy = { - compartment: [ - { - m: { - id: 9, - labels: ['DatabaseObject', 'Compartment'], - props: { dbId: 70101, displayName: 'cytosol', schemaClass: 'Compartment' }, - }, - n: null, + it("loads what findAll did not bring, outgoing relationships too, each event's own", async () => { + const compartment = (id, dbId, displayName) => [ + { + m: { + id, + labels: ['DatabaseObject', 'Compartment'], + props: { dbId, displayName, schemaClass: 'Compartment' }, }, - ], + n: null, + }, + ]; + lazy = { + 'compartment OUTGOING 200': compartment(9, 70101, 'cytosol'), + 'compartment OUTGOING 300': compartment(10, 7660, 'nucleoplasm'), }; const response = await get('R-HSA-100/containedEvents/compartment'); expect(await response.text()).toBe( - '[70101\tcytosol\tCompartment\n, 70101\tcytosol\tCompartment\n]' + '[70101\tcytosol\tCompartment\n, 7660\tnucleoplasm\tCompartment\n]' ); lazy = {}; }); diff --git a/tools/content-node/diagram-search.spec.mjs b/tools/content-node/diagram-search.spec.mjs index 62f69506..02b45348 100644 --- a/tools/content-node/diagram-search.spec.mjs +++ b/tools/content-node/diagram-search.spec.mjs @@ -15,7 +15,13 @@ const objects = { }; vi.mock('./graph.ts', () => ({ - read: async (_cypher, params) => (objects[params.id] ? [objects[params.id]] : []), + // Only the object check as the service writes it: a wrong property (a + // diagram flag read from hasEHLD, say) finds nothing. + read: async (cypher, params) => + cypher.includes('RETURN labels(n) AS labels, n.hasDiagram AS hasDiagram LIMIT 1') && + objects[params.id] + ? [objects[params.id]] + : [], configured: () => true, close: async () => {}, envSetting: (key, fallback = '') => process.env[key] || fallback, @@ -254,9 +260,23 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => { }); const none = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); expect(await none.text()).toBe('{"diagramEntity":"R-HSA-68886","inDiagram":false}'); - answer = () => ({ response: { numFound: 1, docs: [{ occurrences: ['R-HSA-68886:true:x'] }] } }); - const short = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); - expect(short.status).toBe(500); + // Java's ArrayIndexOutOfBoundsException, with its own message + // (GlobalExceptionHandler.handleUnclassified): extractRawOccurrence reads + // [0], [2], [3], then [1], so three parts fail at 3, two at 2 -- and + // one at 2, not 1: the order is Java's, not the parts'. + for (const [occurrence, message] of [ + ['R-HSA-68886:true:x', 'Index 3 out of bounds for length 3'], + ['R-HSA-68886:true', 'Index 2 out of bounds for length 2'], + ['R-HSA-68886', 'Index 2 out of bounds for length 1'], + ]) { + answer = () => ({ response: { numFound: 1, docs: [{ occurrences: [occurrence] }] } }); + const short = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); + expect([occurrence, short.status, (await short.json()).messages]).toEqual([ + occurrence, + 500, + [message], + ]); + } }); it('asks again in every scope, then is the 404', async () => { diff --git a/tools/content-node/discover.spec.mjs b/tools/content-node/discover.spec.mjs index d7060667..d09e500c 100644 --- a/tools/content-node/discover.spec.mjs +++ b/tools/content-node/discover.spec.mjs @@ -209,7 +209,9 @@ const graph = if (/AS related/.test(cypher)) return [{ labels: (load[0] ?? LOADED).root.labels, related: load.length > 0 }]; if (/MATCH \(n\)-\[r\]-\(m\)/.test(cypher)) return load; - if (/DBInfo/.test(cypher)) return [{ version: 97 }]; + // The release as the service reads it: releaseNumber, not another property. + if (cypher === 'MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1') + return [{ version: 97 }]; // The edit's author, with no stoichiometry on the relationship: once. if (/<-\[r:author\]-/.test(cypher) && Number(params.dbId) === 8) return [{ m: { dbId: 20, surname: 'Roe' }, n: null }]; @@ -285,5 +287,12 @@ describe("/data/discover/{identifier}: Java's errors", () => { const load = asked.find((cypher) => /MATCH \(n\)-\[r\]-\(m\)/.test(cypher)); expect(load).toBeDefined(); expect(load).not.toMatch(/ORDER BY|OPTIONAL/); + // The object by the id asked, and the three columns mapResult reads. + expect(load?.replace(/\s+/g, ' ')).toBe( + 'MATCH (n:DatabaseObject) WHERE n.stId = $id WITH n LIMIT 1 MATCH (n)-[r]-(m) ' + + 'RETURN {id: id(n), labels: labels(n), props: properties(n)} AS root, ' + + 'COLLECT({id: id(r), type: type(r), start: id(startNode(r)), end: id(endNode(r)), props: properties(r)}) AS relationships, ' + + 'COLLECT({id: id(m), labels: labels(m), props: properties(m)}) AS nodes' + ); }); }); diff --git a/tools/content-node/flag.spec.mjs b/tools/content-node/flag.spec.mjs index 02090019..ac874b49 100644 --- a/tools/content-node/flag.spec.mjs +++ b/tools/content-node/flag.spec.mjs @@ -20,8 +20,15 @@ const species = { vi.mock('./graph.ts', () => ({ read: async (cypher, params) => { - if (cypher.includes(':Species')) return [species[params.name] ?? []].flat(); - return objects[params.id] ? [objects[params.id]] : []; + // Only the queries as the service writes them: the species by exact + // name among its names, and the object check reading hasDiagram. + if (cypher.includes(':Species')) + return cypher.includes('MATCH (n:Species) WHERE $name IN n.name RETURN') + ? [species[params.name] ?? []].flat() + : []; + return cypher.includes('n.hasDiagram AS hasDiagram') && objects[params.id] + ? [objects[params.id]] + : []; }, configured: () => true, close: async () => {}, @@ -127,6 +134,18 @@ describe('/diagram/{pathwayId}/flag', () => { ]); }); + it("is Java's index failure for an occurrence of fewer than four parts", async () => { + // extractRawOccurrence reads [0], [2], [3], then [1]: two parts fail at 2. + answer = () => ({ + response: { numFound: 1, docs: [{ stId: 'R-HSA-9', occurrences: ['R-HSA-446203:true'] }] }, + }); + const response = await get('diagram/R-HSA-446203/flag?query=CTSA'); + expect([response.status, (await response.json()).messages]).toEqual([ + 500, + ['Index 2 out of bounds for length 2'], + ]); + }); + it('flags through interactors when asked, and is the 404 for nothing', async () => { asked = []; answer = () => ({ response: { numFound: 0, docs: [] } }); @@ -147,6 +166,9 @@ describe('/diagram/{pathwayId}/flag', () => { expect([bad.status, (await bad.json()).messages]).toEqual([400, ["'xyz' cannot be found"]]); const blank = await get('diagram/R-HSA-446203/flag?query=CTSA&includeInteractors=%20'); expect(blank.status).toBe(500); + expect((await blank.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); expect(asked).toHaveLength(0); }); }); @@ -214,10 +236,24 @@ describe('/fireworks/flag', () => { ]); }); - it("is Java's failure, not a guess, when two Species share the name", async () => { + it('is a failure, before Solr is asked, when two Species share the name', async () => { asked = []; const response = await get('fireworks/flag?query=x&species=Twice'); + // Status only: Java's message here is Spring Data's + // IncorrectResultSizeDataAccessException, which has not been measured. expect(response.status).toBe(500); expect(asked).toHaveLength(0); }); + + it("is Java's index failure for an occurrence of fewer than four parts", async () => { + // fireworksFlagging reads [0], then [3]: one part fails at 3, not 1. + answer = () => ({ + response: { numFound: 1, docs: [{ llps: ['R-HSA-1'], occurrences: ['R-HSA-1'] }] }, + }); + const response = await get('fireworks/flag?query=x&species=Mus%20musculus'); + expect([response.status, (await response.json()).messages]).toEqual([ + 500, + ['Index 3 out of bounds for length 1'], + ]); + }); }); diff --git a/tools/content-node/icon.spec.mjs b/tools/content-node/icon.spec.mjs index f3dda775..d08339cd 100644 --- a/tools/content-node/icon.spec.mjs +++ b/tools/content-node/icon.spec.mjs @@ -83,7 +83,10 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => { }); it('fails, as Java does, on an entity with fewer than four parts', () => { - expect(() => searchEntry({ ...icon, iconPhysicalEntities: ['a#b#c#'] }, {})).toThrow(); + // Its ArrayIndexOutOfBoundsException: read [0] to [3], three parts fail at 3. + expect(() => searchEntry({ ...icon, iconPhysicalEntities: ['a#b#c#'] }, {})).toThrow( + expect.objectContaining({ javaMessage: 'Index 3 out of bounds for length 3' }) + ); }); it('id is the stId when there is one, else the dbId', () => { diff --git a/tools/content-node/query-attribute-endpoint.spec.mjs b/tools/content-node/query-attribute-endpoint.spec.mjs index df1263f8..cacac83b 100644 --- a/tools/content-node/query-attribute-endpoint.spec.mjs +++ b/tools/content-node/query-attribute-endpoint.spec.mjs @@ -9,14 +9,29 @@ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; let root; let lazy = {}; const asked = []; +// The lazy loads by what they ask: `${type} ${direction} ${dbId}`, read from +// the query and its parameter -- a load in the wrong direction, or of +// another object, finds nothing. Directions are Java's model's +// (domain-snapshot.json): an edit's `modified` comes in to the object, and +// so does the `hasComponent` behind a complex's componentOf. vi.mock('./graph.ts', () => ({ - read: async (cypher) => { + read: async (cypher, params) => { asked.push(cypher); + // /query/{id}/stId's and /query/{id}/normalPathway's own queries. + if (/n\.stId AS stId, n\.schemaClass AS schemaClass/.test(cypher)) + return root ? [{ stId: root.props.stId ?? null, schemaClass: root.props.schemaClass }] : []; + if (/\[:normalPathway\]/.test(cypher)) + return root + ? [{ labels: root.labels, dbId: null, stId: null, displayName: null, targetLabels: null }] + : []; if (/RETURN labels\(n\) AS labels/.test(cypher)) return root ? [{ labels: root.labels }] : []; if (/AS root/.test(cypher)) return root ? [{ root, order: [], nodes: [], relationships: [] }] : []; - for (const [type, rows] of Object.entries(lazy)) - if (cypher.includes(`[r:${type}]`)) return rows; + const load = /\{dbId:\$dbId\}\)(<-|-)\[r:(\w+)\](->|-)/.exec(cypher); + if (load) { + const direction = load[1] === '<-' ? 'INCOMING' : 'OUTGOING'; + return lazy[`${load[2]} ${direction} ${Number(params.dbId)}`] ?? []; + } return []; }, configured: () => true, @@ -55,7 +70,7 @@ const loaded = (id, schemaClass, labels, n = null) => ({ describe('a single object loaded lazily', () => { it("is the one there is, asked for by the getter's class", async () => { root = pathway; - lazy = { modified: [loaded(20, 'InstanceEdit', ['InstanceEdit'])] }; + lazy = { 'modified INCOMING 9': [loaded(20, 'InstanceEdit', ['InstanceEdit'])] }; asked.length = 0; const response = await get('query/R-HSA-9/modified'); expect([response.status, await response.text()]).toEqual([200, 'R-HSA-20\to20\tInstanceEdit']); @@ -65,7 +80,7 @@ describe('a single object loaded lazily', () => { it("is nothing when there are two, as findByRelationship gives: Java's 404", async () => { root = pathway; lazy = { - modified: [ + 'modified INCOMING 9': [ loaded(20, 'InstanceEdit', ['InstanceEdit']), loaded(21, 'InstanceEdit', ['InstanceEdit']), ], @@ -84,12 +99,41 @@ describe('a collection loaded lazily', () => { labels: ['DatabaseObject', 'PhysicalEntity', 'Complex'], props: { dbId: 5, stId: 'R-HSA-5', displayName: 'C', schemaClass: 'Complex' }, }; - lazy = { hasComponent: [loaded(30, 'Complex', ['PhysicalEntity', 'Complex'], 2)] }; + // The complexes it is a component of: hasComponent, coming in. + lazy = { + 'hasComponent INCOMING 5': [loaded(30, 'Complex', ['PhysicalEntity', 'Complex'], 2)], + }; const response = await get('query/R-HSA-5/componentOf'); expect(await response.text()).toBe('R-HSA-30\to30\tComplex\nR-HSA-30\to30\tComplex\n'); }); }); +describe('the attribute as it was sent', () => { + // Routing matches stId and normalPathway in any case; Java's 404 names + // the attribute as the request spelled it. + it("is named in /query/{id}/stId's 404 for an object without a stable id", async () => { + root = { + id: 6, + labels: ['DatabaseObject', 'Taxon', 'Species'], + props: { dbId: 48887, displayName: 'Homo sapiens', schemaClass: 'Species' }, + }; + for (const spelled of ['stId', 'STID', 'StId']) + expect(await messages(await get(`query/48887/${spelled}`))).toEqual([ + 404, + [`Attribute: '${spelled}' has not been found for object: Species`], + ]); + }); + + it("is named in /query/{id}/normalPathway's 404 for an object without one", async () => { + root = pathway; + for (const spelled of ['normalPathway', 'NormalPathway', 'NORMALPATHWAY']) + expect(await messages(await get(`query/R-HSA-9/${spelled}`))).toEqual([ + 404, + [`Attribute: '${spelled}' has not been found for object: Pathway`], + ]); + }); +}); + describe("Java's other answers", () => { it("leaves Java's own /query/abridged/{id}, /query/enhanced/{id} and /query/{id}/more alone", async () => { root = pathway; diff --git a/tools/content-node/query-ids.spec.mjs b/tools/content-node/query-ids.spec.mjs index 733bdc10..2fd7f460 100644 --- a/tools/content-node/query-ids.spec.mjs +++ b/tools/content-node/query-ids.spec.mjs @@ -1,15 +1,41 @@ // @vitest-environment node // // POST /data/query/ids and /data/query/ids/map -- a batch of objects by id -- -// with a stand-in graph that finds nothing, for Java's errors, and the two +// with a stand-in graph, for Java's errors and its answers, and the two // orders Java writes in: a HashSet of DatabaseObjects and a HashMap of ids. // The expected orders are the JDK's own (11, the version the service runs), // computed with graph-core's DatabaseObject.hashCode -- dbId, then stId, // then displayName, 31 apart. Java's answers measured 17 Oct 2026. import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +// Three objects the stand-in graph finds, by the id each is asked by. +const FOUND = { + 'R-HSA-109581': [109581, 'Apoptosis'], + 'R-HSA-1640170': [1640170, 'Cell Cycle'], + 199420: [199420, 'PTEN [cytosol]'], +}; +let finds = false; vi.mock('./graph.ts', () => ({ - read: async () => [], + read: async (cypher, params) => { + const known = finds ? FOUND[params?.id] : undefined; + if (!known) return []; + const [dbId, displayName] = known; + const labels = ['DatabaseObject', 'Event', 'Pathway']; + if (/AS root/.test(cypher)) + return [ + { + root: { + id: dbId, + labels, + props: { dbId, stId: `R-HSA-${dbId}`, displayName, schemaClass: 'Pathway' }, + }, + order: [], + nodes: [], + relationships: [], + }, + ]; + return [{ labels }]; + }, configured: () => true, close: async () => {}, envSetting: () => '', @@ -74,6 +100,35 @@ const post = (path, body, type = 'text/plain') => body, }); +describe("Java's answers", () => { + it('writes /ids in the HashSet order, not the order sent; the ids not found left out', async () => { + finds = true; + try { + const response = await post('ids', 'R-HSA-109581, R-HSA-1640170, 199420, xyz'); + expect((await response.json()).map((o) => o.dbId)).toEqual([1640170, 109581, 199420]); + } finally { + finds = false; + } + }); + + it('writes /ids/map keyed by each id as sent, in the HashMap order', async () => { + finds = true; + try { + const response = await post('ids/map', 'R-HSA-109581, R-HSA-1640170, 199420, xyz'); + // Read from the text: JSON.parse would put "199420", a key that looks + // like an index, first whatever order the answer wrote. + const text = await response.text(); + expect([...text.matchAll(/"([^"]+)":\{"dbId":(\d+)/g)].map((m) => [m[1], m[2]])).toEqual([ + ['R-HSA-1640170', '1640170'], + ['R-HSA-109581', '109581'], + ['199420', '199420'], + ]); + } finally { + finds = false; + } + }); +}); + describe("Java's errors", () => { it('refuses a body that is not text/plain, before anything else', async () => { const response = await post('ids', 'x', 'application/json'); diff --git a/tools/content-node/query-search.spec.mjs b/tools/content-node/query-search.spec.mjs index 61551768..1d2ecb53 100644 --- a/tools/content-node/query-search.spec.mjs +++ b/tools/content-node/query-search.spec.mjs @@ -277,6 +277,9 @@ describe('/query', () => { answer = (handler) => (handler.endsWith('/facet') ? facets(8) : grouped([])); const blank = await get('query?query=TP53&scope=%20'); expect(blank.status).toBe(500); + expect((await blank.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); answer = () => ({ response: { numFound: 0, docs: [] } }); const empty = await get('query?query=&scope=%20'); expect(empty.status).toBe(404); @@ -332,12 +335,11 @@ describe('/query', () => { const body = await ( await get('query?query=%20P04637&cluster=false&Start%20row=900&scope=BOTH') ).json(); - expect(body.targetResults).toEqual( - expect.arrayContaining([ - { resource: 'UNIPROT', target: false }, - { term: 'P04637', resource: 'UNIPROT', target: true }, - ]) - ); + // In the order Java's HashSet writes them, not merely both present. + expect(body.targetResults).toEqual([ + { term: 'P04637', resource: 'UNIPROT', target: true }, + { resource: 'UNIPROT', target: false }, + ]); }); it("is Java's 500 when Solr's grouped answer has no grouping", async () => { @@ -345,6 +347,9 @@ describe('/query', () => { handler.endsWith('/facet') ? facets(8) : { response: { numFound: 0, docs: [] } }; const response = await get('query?query=TP53'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); }); it('leaves out the type of a group with none', async () => { @@ -405,6 +410,9 @@ describe('/query/paginated', () => { }; const response = await get('query/paginated?query=TP53&page=1&rowCount=10'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); // This search's own report, not a late one from the test before. await until(() => reports.some((report) => report.body === '{"term":"TP53","resource":""}')); }); @@ -421,6 +429,9 @@ describe('/query/paginated', () => { : grouped([['Protein', [doc('1', 'TP53')]]]); const response = await get('query/paginated?query=TP53&page=1&rowCount=10'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); }); it("is Java's 400 for a page or row count that is not positive, and Spring's when absent", async () => { diff --git a/tools/content-node/references-mapping.spec.mjs b/tools/content-node/references-mapping.spec.mjs index 63f4b645..f62a2521 100644 --- a/tools/content-node/references-mapping.spec.mjs +++ b/tools/content-node/references-mapping.spec.mjs @@ -84,7 +84,7 @@ describe('/references/mapping/{identifier}', () => { const response = await get('P1'); expect((await response.json()).map((e) => e.dbId)).toEqual([1]); expect(last()[0].replace(/\s+/g, ' ').trim()).toMatch( - /^MATCH \(rd:ReferenceDatabase\)<--\(n\{identifier:\$identifier\}\)<-\[:referenceEntity\|referenceSequence\|crossReference\|referenceGene\*\]-\(pe:PhysicalEntity\) WITH DISTINCT pe MATCH \(pe\)-\[:referenceEntity\]->\(n:ReferenceEntity\) ?RETURN DISTINCT n/ + /^MATCH \(rd:ReferenceDatabase\)<--\(n\{identifier:\$identifier\}\)<-\[:referenceEntity\|referenceSequence\|crossReference\|referenceGene\*\]-\(pe:PhysicalEntity\) WITH DISTINCT pe MATCH \(pe\)-\[:referenceEntity\]->\(n:ReferenceEntity\) ?RETURN DISTINCT n, labels\(n\) AS labels$/ ); expect(last()[1]).toEqual({ identifier: 'P1' }); }); diff --git a/tools/content-node/service.ts b/tools/content-node/service.ts index 5369b6ab..17c23e61 100644 --- a/tools/content-node/service.ts +++ b/tools/content-node/service.ts @@ -6464,8 +6464,11 @@ async function fromSolr(request, path, work) { headers: { 'Retry-After': '5' }, }; console.error(`${path}: ${failure.message}`); - // What Java answers when its own code fails, not Solr: a - // NullPointerException, through GlobalExceptionHandler. + // What Java answers when its own code fails, not Solr: any exception + // but a NullPointerException with its own message (handleUnclassified), + // a NullPointerException with its fixed one, through GlobalExceptionHandler. + if (failure.javaMessage) + return javaError(request, 500, 'INTERNAL_SERVER_ERROR', failure.javaMessage); if (failure.unexpected) return javaError( request, @@ -6698,7 +6701,7 @@ function solrStrings(doc: SolrDoc, field: string) { } /** String.split on a literal or a pattern, as Java's: trailing empty strings removed. */ -function javaSplit(text, separator) { +function javaSplit(text: string, separator: string | RegExp): string[] { const parts = text.split(separator); // No separator at all is the text itself, even empty; otherwise every // trailing empty string goes, so "," is nothing at all. @@ -6725,11 +6728,7 @@ function iconPhysicalEntities(doc) { // what is written, but the TreeSet still orders by it. const names: string[] = []; for (const packed of solrStrings(doc, 'iconPhysicalEntities')) { - const [stId, type, name, compartments] = javaSplit(packed, '#'); - if (compartments === undefined) - throw Object.assign(new Error('an icon physical entity with fewer than four parts'), { - unexpected: true, - }); + const [stId, type, name, compartments] = fourParts(packed, '#', [0, 1, 2, 3]); const displayName = name + (javaIsBlank(compartments) ? '' : ` [${compartments}]`); const at = names.findIndex((other) => caseInsensitive(displayName, other) <= 0); if (at > -1 && caseInsensitive(displayName, names[at]) === 0) continue; @@ -7102,18 +7101,46 @@ function speciesWideParams(query): SolrParams { ]; } +/** + * Java's ArrayIndexOutOfBoundsException for an array of `length` read at + * `reads`, in the order Java reads them (search-core's bytecode): the first + * index past the end is the one named. GlobalExceptionHandler answers it + * with that message (handleUnclassified), not the NullPointerException's. + */ +function outOfBounds(length: number, reads: number[], raw: string): Error { + const index = reads.find((at) => at >= length) ?? length; + return Object.assign(new Error(`${length} parts, read at ${index}: ${raw}`), { + javaMessage: `Index ${index} out of bounds for length ${length}`, + }); +} + +/** + * The first four parts of `raw` split as Java splits it, or, for fewer, + * Java's failure reading them in the order `reads` (outOfBounds). + */ +function fourParts( + raw: string, + separator: string, + reads: number[] +): [string, string, string, string] { + const parts = javaSplit(raw, separator); + const [a, b, c, d] = parts; + if (a === undefined || b === undefined || c === undefined || d === undefined) + throw outOfBounds(parts.length, reads, raw); + return [a, b, c, d]; +} + +/** The parts extractRawOccurrence reads, in its order: [0], [2], [3], then [1]. */ +const OCCURRENCE_READS = [0, 2, 3, 1]; + /** * SolrConverter's extractRawOccurrence: "diagram:inDiagram:occurrences: * interactsWith", '#' for none, as a DiagramOccurrencesResult -- each list a * HashSet, so written in a java.util.HashMap's order, and left out empty. * Fewer than four parts is Java's ArrayIndexOutOfBoundsException. */ -function rawOccurrence(raw) { - const line = javaSplit(raw, ':'); - if (line.length < 4) - throw Object.assign(new Error(`an occurrence with fewer than four parts: ${raw}`), { - unexpected: true, - }); +function rawOccurrence(raw: string) { + const line = fourParts(raw, ':', OCCURRENCE_READS); const set = (text) => { if (text === '#') return undefined; const map = new JavaHashMap(); @@ -7177,11 +7204,7 @@ function diagramFlags(answer, pathway, field) { for (const doc of answer?.response?.docs ?? []) { for (const raw of solrStrings(doc, field)) { if (!raw.startsWith(pathway)) continue; - const line = javaSplit(raw, ':'); - if (line.length < 4) - throw Object.assign(new Error(`an occurrence with fewer than four parts: ${raw}`), { - unexpected: true, - }); + const line = fourParts(raw, ':', OCCURRENCE_READS); // extractRawOccurrence with the document: the entity is its stId // when it is in the diagram, and in the diagram if that is non-empty. const entity = line[1].toLowerCase() === 'true' ? solrValue(doc, 'stId') : null; @@ -7219,11 +7242,8 @@ function speciesWideFlags(answer, abbreviation) { for (const doc of answer?.response?.docs ?? []) { for (const id of solrStrings(doc, 'llps')) llps.set(id, true); for (const raw of solrStrings(doc, 'occurrences')) { - const line = javaSplit(raw, ':'); - if (line.length < 4) - throw Object.assign(new Error(`an occurrence with fewer than four parts: ${raw}`), { - unexpected: true, - }); + // fireworksFlagging reads [0], then [3]. + const line = fourParts(raw, ':', [0, 3]); if (line[3] !== '#') interactsWith.set(line[0], true); } } diff --git a/tools/content-node/species-search.spec.mjs b/tools/content-node/species-search.spec.mjs index 88ca7c73..e92c6a5c 100644 --- a/tools/content-node/species-search.spec.mjs +++ b/tools/content-node/species-search.spec.mjs @@ -257,7 +257,11 @@ describe('/fireworks, the species-wide search', () => { 500, ['Something unexpected happened and the error has been reported.'], ]); - await new Promise((resolve) => setTimeout(resolve, 50)); + // Reports go in order: once a later search's report has come, one from + // this search would have come before it. + answer = () => ({ response: { numFound: 0, docs: [] } }); + await get('?query=marker&scope=BOTH'); + await until(() => reports.some((report) => report.body.includes('"term":"marker"'))); expect(reports.filter((report) => report.body.includes('"term":"x"'))).toHaveLength(0); }); @@ -313,6 +317,9 @@ describe('/fireworks, the species-wide search', () => { : { response: { numFound: 0, docs: [] } }; const response = await get('?query=x&scope=BOTH'); expect(response.status).toBe(500); + expect((await response.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); }); it('reports the targets in the fresh HashSet Collectors.toSet() makes', async () => { @@ -343,6 +350,9 @@ describe('/fireworks, the species-wide search', () => { expect(missing.status).toBe(400); const blank = await get('?query=x&includeInteractors=%20'); expect(blank.status).toBe(500); + expect((await blank.json()).messages).toEqual([ + 'Something unexpected happened and the error has been reported.', + ]); expect(asked).toHaveLength(0); }); diff --git a/tools/implicit-any-baseline.json b/tools/implicit-any-baseline.json index 0f4583c1..1092dfc6 100644 --- a/tools/implicit-any-baseline.json +++ b/tools/implicit-any-baseline.json @@ -1,11 +1,11 @@ { - "tools/content-node/citation.spec.ts": 28, + "tools/content-node/citation.spec.ts": 10, "tools/content-node/citation.ts": 44, "tools/content-node/domain.spec.ts": 16, "tools/content-node/domain.ts": 131, "tools/content-node/java-hashmap.ts": 26, "tools/content-node/parameters.ts": 25, - "tools/content-node/service.ts": 429, + "tools/content-node/service.ts": 424, "tools/render/pptx.ts": 72, "tools/render/render-core.ts": 18, "tools/render/service.ts": 23 diff --git a/tools/loose-baseline.json b/tools/loose-baseline.json index b2105abe..e35fe5d6 100644 --- a/tools/loose-baseline.json +++ b/tools/loose-baseline.json @@ -1,5 +1,5 @@ { - "tools/content-node/citation.spec.ts": 13, + "tools/content-node/citation.spec.ts": 6, "tools/content-node/citation.ts": 170, "tools/content-node/classify.ts": 18, "tools/content-node/domain.spec.ts": 77, @@ -11,7 +11,7 @@ "tools/content-node/parameters.ts": 48, "tools/content-node/psicquic.spec.ts": 1, "tools/content-node/report.ts": 3, - "tools/content-node/service.ts": 1529, + "tools/content-node/service.ts": 1476, "tools/content-node/snapshot-openapi.spec.ts": 5, "tools/content-node/snapshot-rfd.ts": 4, "tools/content-node/snapshot-schema.ts": 11,