diff --git a/specs/013-server-types/tasks.md b/specs/013-server-types/tasks.md index 393fb010..c9f042db 100644 --- a/specs/013-server-types/tasks.md +++ b/specs/013-server-types/tasks.md @@ -73,7 +73,7 @@ Each task: rename to `.ts`, update imports, file names that name it (R3 list), f - [ ] T023 The tools beside the services that they do not import (`tools/java-parity`, `tools/library-update`, `tools/svg-export-harness`, the check specs): converted the same way, or recorded as left and why. - Measured 23 Oct with `checkJs` on: 1,229 errors in 47 `.mjs` files, 70 of them in the five tools and 1,159 in 42 specs. The tools are converted, typed, held at zero both counts, and still run as before (parity and the library guard directly under Node, the harness through its new `playwright.config.ts`, which also mends the README's command: it had found no tests since the harness stopped being called a spec) — #643. The specs go next, in batches: the ten check specs outside the content service (load, started, workflows, check-move, check-baseline-totals, the HAR, flaky and lint checks, and content-node's hermetic), 60 errors — #644. The content service's ten smallest (exporter, cache-headers, molecule-details-bound, component-of, diseases, participating-entities, interactors, query-ids, map-results, mapping), 139 — #645. The next eight (pathways-low, complexes, overview-export, schema-objects, orthology, query-attribute-endpoint, interactors-static, referrers), 183 — #647. + Measured 23 Oct with `checkJs` on: 1,229 errors in 47 `.mjs` files, 70 of them in the five tools and 1,159 in 42 specs. The tools are converted, typed, held at zero both counts, and still run as before (parity and the library guard directly under Node, the harness through its new `playwright.config.ts`, which also mends the README's command: it had found no tests since the harness stopped being called a spec) — #643. The specs go next, in batches: the ten check specs outside the content service (load, started, workflows, check-move, check-baseline-totals, the HAR, flaky and lint checks, and content-node's hermetic), 60 errors — #644. The content service's ten smallest (exporter, cache-headers, molecule-details-bound, component-of, diseases, participating-entities, interactors, query-ids, map-results, mapping), 139 — #645. The next eight (pathways-low, complexes, overview-export, schema-objects, orthology, query-attribute-endpoint, interactors-static, referrers), 183 — #647. Seven more (contained-events, pathways-of, flag, query-attribute, discover, diagram-search, references-mapping), 235 — #649. - [ ] T024 `CLAUDE.md`: the rule for new service code (typed, and a load test entry for a new entry point) if one emerges. diff --git a/tools/content-node/contained-events.spec.mjs b/tools/content-node/contained-events.spec.ts similarity index 71% rename from tools/content-node/contained-events.spec.mjs rename to tools/content-node/contained-events.spec.ts index b0256f80..b43dbca6 100644 --- a/tools/content-node/contained-events.spec.mjs +++ b/tools/content-node/contained-events.spec.ts @@ -4,11 +4,22 @@ // through the service, with a stand-in graph: graph-core's query, its records // mapped as findAll maps them, and what a getter loads when asked. The events // here are invented; Java's answers were measured 18 Oct 2026. +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type { GraphNode } from './graph.ts'; -const asked = []; -let lazy = {}; -const node = (id, labels, props) => ({ id, labels: ['DatabaseObject', 'Event', ...labels], props }); +/** A query's parameters, as the service passes them. */ +type Params = { stId?: unknown; dbId?: unknown; [name: string]: unknown }; +/** Each query the stand-in graph was asked, with its parameters. */ +const asked: [string, Params][] = []; +/** The lazy loads' rows, by `${type} ${direction} ${dbId}`. */ +let lazy: Record = {}; +const node = (id: number, labels: string[], props: GraphNode['props']): GraphNode => ({ + id, + labels: ['DatabaseObject', 'Event', ...labels], + props, +}); const P = node(1, ['Pathway'], { dbId: 100, stId: 'R-HSA-100', @@ -27,20 +38,21 @@ const B = node(3, ['ReactionLikeEvent', 'Reaction'], { displayName: 'B', schemaClass: 'Reaction', }); -const hasEvent = (id, start, end, order) => ({ +const hasEvent = (id: number, start: number, end: number, order: number) => ({ id, type: 'hasEvent', start, end, props: { order, stoichiometry: 1 }, }); -let records; +/** The contained-events query's records, when a test sets its own. */ +let records: unknown[] | undefined; const RECORDS = [ { e: A, 'COLLECT(r)': [[hasEvent(10, 1, 2, 1)]], 'COLLECT(p)': [P] }, { e: B, 'COLLECT(r)': [[hasEvent(11, 1, 3, 0)]], 'COLLECT(p)': [P] }, ]; vi.mock('./graph.ts', () => ({ - read: async (cypher, params) => { + read: async (cypher: string, params: Params) => { asked.push([cypher, params]); if (cypher.includes('[r:hasEvent*1..100]')) return params.stId === 'R-HSA-100' || params.dbId ? (records ?? RECORDS) : []; @@ -61,34 +73,46 @@ vi.mock('./graph.ts', () => ({ const { app } = await import('./server.ts'); -let base; -let server; +let base: string; +let server: Server; beforeAll(async () => { - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); -afterAll(() => new Promise((resolve) => server.close(resolve))); +afterAll(() => new Promise((resolve) => server.close(() => resolve()))); -const get = (path) => fetch(`${base}/ContentService/data/pathway/${path}`); -const messages = async (response) => [response.status, (await response.json()).messages]; +/** The last query asked; none at all is the test's failure, not undefined. */ +const last = () => { + const query = asked.at(-1); + if (!query) throw new Error('the graph was asked nothing'); + return query; +}; +const get = (path: string) => fetch(`${base}/ContentService/data/pathway/${path}`); +/** Status and the messages of Java's error envelope. */ +const messages = async (response: Response) => [ + response.status, + ((await response.json()) as { messages: string[] }).messages, +]; describe('/data/pathway/{id}/containedEvents', () => { it("runs graph-core's query as written, by stable id or dbId, and writes the events", async () => { asked.length = 0; - const written = await (await get('R-HSA-100/containedEvents')).json(); + const written = (await (await get('R-HSA-100/containedEvents')).json()) as ( + number | { dbId: number } + )[]; expect(written.map((event) => (typeof event === 'number' ? event : event.dbId))).toEqual([ 200, 300, ]); - expect(asked.at(-1)[0]).toBe( + expect(last()[0]).toBe( 'MATCH (p:Pathway)-[r:hasEvent*1..100]->(e:Event) WHERE p.stId = $stId RETURN e, COLLECT(r), COLLECT(p)' ); await get('100/containedEvents'); - expect(asked.at(-1)[0]).toBe( + expect(last()[0]).toBe( 'MATCH (p:Pathway)-[r:hasEvent*1..100]->(e:Event) WHERE p.dbId = $dbId RETURN e, COLLECT(r), COLLECT(p)' ); - expect(Number(asked.at(-1)[1].dbId)).toBe(100); + expect(Number(last()[1].dbId)).toBe(100); }); it('is a 404 naming the id for none', async () => { @@ -120,7 +144,7 @@ describe('/data/pathway/{id}/containedEvents/{attributeName}', () => { }); it("loads what findAll did not bring, outgoing relationships too, each event's own", async () => { - const compartment = (id, dbId, displayName) => [ + const compartment = (id: number, dbId: number, displayName: string) => [ { m: { id, diff --git a/tools/content-node/diagram-search.spec.mjs b/tools/content-node/diagram-search.spec.ts similarity index 76% rename from tools/content-node/diagram-search.spec.mjs rename to tools/content-node/diagram-search.spec.ts index 15cdaa78..70f4031c 100644 --- a/tools/content-node/diagram-search.spec.mjs +++ b/tools/content-node/diagram-search.spec.ts @@ -4,9 +4,11 @@ // for the graph and Solr: Java's identifier checks, what search-core asks // Solr, and the answers it writes. Java's answers measured 16 Oct 2026. import { URLSearchParams } from 'node:url'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { describe, expect, it, beforeAll, afterAll, vi } from 'vitest'; -const objects = { +const objects: Record = { 'R-HSA-8848021': { labels: ['Pathway', 'Event', 'DatabaseObject'], hasDiagram: true }, 'R-HSA-68886': { labels: ['Pathway', 'Event', 'DatabaseObject'], hasDiagram: true }, 'R-HSA-1': { labels: ['Pathway', 'Event', 'DatabaseObject'], hasDiagram: false }, @@ -17,52 +19,64 @@ const objects = { vi.mock('./graph.ts', () => ({ // 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) => + read: async (cypher: string, params: { id: string }) => 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, + envSetting: (key: string, fallback = '') => process.env[key] || fallback, })); const { app } = await import('./server.ts'); -let base; -let server; -let solrServer; -let asked; -let answer; +let base: string; +let server: Server; +/** A form body as the service sent it to Solr: name and value, in order. */ +type Sent = [string, string][]; +let solrServer: Server; +/** What the stand-in Solr was asked: the path, and the form it was sent. */ +let asked: [string, Sent][]; +/** The stand-in Solr's answer to a request. */ +let answer: (url: string, sent: Sent) => unknown; beforeAll(async () => { const { createServer } = await import('node:http'); solrServer = createServer((request, response) => { let body = ''; - request.on('data', (chunk) => (body += chunk)); + request.on('data', (chunk: Buffer) => (body += chunk.toString())); request.on('end', () => { const sent = [...new URLSearchParams(body)]; - asked.push([request.url.replace('/solr/reactome', ''), sent]); + const url = request.url ?? ''; + asked.push([url.replace('/solr/reactome', ''), sent]); response.setHeader('Content-Type', 'application/json'); - response.end(JSON.stringify(answer(request.url, sent))); + response.end(JSON.stringify(answer(url, sent))); }); }); - await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); - process.env.SOLR_URL = `http://127.0.0.1:${solrServer.address().port}/solr/reactome`; - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); + process.env['SOLR_URL'] = + `http://127.0.0.1:${(solrServer.address() as AddressInfo).port}/solr/reactome`; + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(async () => { - delete process.env.SOLR_URL; - await new Promise((resolve) => server.close(resolve)); - await new Promise((resolve) => solrServer.close(resolve)); + delete process.env['SOLR_URL']; + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => solrServer.close(() => resolve())); }); -const get = (path) => fetch(`${base}/ContentService/search/${path}`); -const fqs = (sent) => sent.filter(([name]) => name === 'fq').map(([, value]) => value); +/** The nth request the stand-in Solr was asked; one not asked is the test's failure. */ +const request = (at: number) => { + const found = asked[at]; + if (!found) throw new Error(`Solr was not asked a request ${at}`); + return found; +}; +const get = (path: string) => fetch(`${base}/ContentService/search/${path}`); +const fqs = (sent: Sent) => sent.filter(([name]) => name === 'fq').map(([, value]) => value); const mad = { dbId: '141400', stId: 'R-HSA-141400', @@ -80,13 +94,13 @@ describe("Java's identifier checks, after Spring's parameters", () => { ['diagram/R-HSA-141433?query=MAD', "'R-HSA-141433' does not belong to a pathway"], ['diagram/R-HSA-1?query=MAD', "'R-HSA-1' the pathway does not have diagram"], ['diagram/xyz/occurrences/abc', "'abc' cannot be found and 'xyz' cannot be found"], - ]) { + ] as const) { const response = await get(path); - expect([path, response.status, (await response.json()).messages]).toEqual([ + expect([ path, - 400, - [message], - ]); + response.status, + ((await response.json()) as { messages: string[] }).messages, + ]).toEqual([path, 400, [message]]); } expect(asked).toHaveLength(0); }); @@ -102,28 +116,29 @@ describe("Java's identifier checks, after Spring's parameters", () => { 'diagram/R-HSA-68886/occurrences/99999999999999999999.1', 'For input string: "99999999999999999999"', ], - ]) { + ] as const) { const response = await get(path); - expect([path, response.status, (await response.json()).messages]).toEqual([ + expect([ path, - 500, - [message], - ]); + response.status, + ((await response.json()) as { messages: string[] }).messages, + ]).toEqual([path, 500, [message]]); } expect(asked).toHaveLength(0); }); it("checks the diagram before the object, so the diagram's 500 is the one", async () => { const response = await get('diagram/.../occurrences/99999999999999999999'); - expect((await response.json()).messages).toEqual(['Index 0 out of bounds for length 0']); + expect(((await response.json()) as { messages: string[] }).messages).toEqual([ + 'Index 0 out of bounds for length 0', + ]); }); it("is Java's 500 for a pathway whose hasDiagram is null", async () => { const response = await get('diagram/R-HSA-2?query=MAD'); - expect([response.status, (await response.json()).messages]).toEqual([ - 500, - ['Something unexpected happened and the error has been reported.'], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [500, ['Something unexpected happened and the error has been reported.']] + ); }); it('checks the ids before a blank scope or includeInteractors fails', async () => { @@ -135,7 +150,7 @@ describe("Java's identifier checks, after Spring's parameters", () => { it("is Spring's 400 for a missing query before any identifier is read", async () => { const response = await get('diagram/xyz'); - expect((await response.json()).messages).toEqual([ + expect(((await response.json()) as { messages: string[] }).messages).toEqual([ "Required request parameter 'query' for method parameter type String is not present", ]); }); @@ -175,10 +190,9 @@ describe('/diagram/{diagram}', () => { asked = []; answer = () => ({ response: { numFound: 0, docs: [] } }); const response = await get('diagram/R-HSA-8848021.1?query=zzz&includeInteractors=true'); - expect([response.status, (await response.json()).messages]).toEqual([ - 404, - ["No entries found for 'zzz' in diagram 'R-HSA-8848021.1'"], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [404, ["No entries found for 'zzz' in diagram 'R-HSA-8848021.1'"]] + ); expect(asked.map(([, sent]) => fqs(sent))).toEqual([ ['-isReferenceSummary:true', 'diagramsWithInteractor:R-HSA-8848021.1'], ['diagramsWithInteractor:R-HSA-8848021.1'], @@ -247,7 +261,7 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => { occurrences: [...'abcdefghijkl'.split(''), 'q'], interactsWith: ['a', 'b'], }); - expect(asked[0][1].find(([name]) => name === 'fl')).toEqual([ + expect(request(0)[1].find(([name]) => name === 'fl')).toEqual([ 'fl', 'occurrencesWithInteractor', ]); @@ -268,14 +282,14 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => { ['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'], - ]) { + ] as const) { 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([ + expect([ occurrence, - 500, - [message], - ]); + short.status, + ((await short.json()) as { messages: string[] }).messages, + ]).toEqual([occurrence, 500, [message]]); } }); @@ -283,10 +297,9 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => { asked = []; answer = () => ({ response: { numFound: 0, docs: [] } }); const response = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433'); - expect([response.status, (await response.json()).messages]).toEqual([ - 404, - ["No occurrences of 'R-HSA-141433' found in 'R-HSA-68886'"], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [404, ["No occurrences of 'R-HSA-141433' found in 'R-HSA-68886'"]] + ); expect(asked.map(([, sent]) => fqs(sent))).toEqual([['hasReferenceEntity:false'], []]); }); }); @@ -336,9 +349,18 @@ describe('/diagram/summary', () => { answer = () => ({ response: { numFound: 1, docs: [{ dbId: '1', fireworksSpecies: ['Homo sapiens'] }] }, }); - const body = await (await get('diagram/summary?query=KIF&diagram=R-HSA-8848021')).json(); - expect(body.diagramResult.entries[0].fireworksSpecies).toBe(undefined); - expect(body.fireworksResult.entries[0].fireworksSpecies).toEqual(['Homo sapiens']); + type Result = { entries: { fireworksSpecies?: string[] }[] }; + const body = (await (await get('diagram/summary?query=KIF&diagram=R-HSA-8848021')).json()) as { + diagramResult: Result; + fireworksResult: Result; + }; + // The first entry, which must be there: an absent one is not "no species". + const first = ({ entries: [entry] }: Result) => { + if (!entry) throw new Error('no entries'); + return entry; + }; + expect(first(body.diagramResult).fireworksSpecies).toBe(undefined); + expect(first(body.fireworksResult).fireworksSpecies).toEqual(['Homo sapiens']); }); it('asks both again in every scope when fireworks finds nothing, for the species given', async () => { @@ -396,7 +418,7 @@ describe('searchEntry for fireworks', () => { ); expect(Object.keys(entry)).toEqual(['dbId', 'id', 'fireworksSpecies', 'iconName', 'icon']); expect( - searchEntry({ dbId: '1', fireworksSpecies: ['Homo sapiens'] }, null).fireworksSpecies + searchEntry({ dbId: '1', fireworksSpecies: ['Homo sapiens'] }, null)['fireworksSpecies'] ).toBe(undefined); }); }); diff --git a/tools/content-node/discover.spec.mjs b/tools/content-node/discover.spec.ts similarity index 82% rename from tools/content-node/discover.spec.mjs rename to tools/content-node/discover.spec.ts index 7b876c05..f02503b7 100644 --- a/tools/content-node/discover.spec.mjs +++ b/tools/content-node/discover.spec.ts @@ -5,13 +5,17 @@ // The builder is tested on its own with invented objects and a stand-in for // graph-core's lazy loading; the handler with a stand-in graph, for Java's // errors. Java's answers were measured 17 Oct 2026. +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; // The stand-in graph answers each query by what it asks for. -let answer = () => []; -const asked = []; +/** A query's parameters, as the service passes them. */ +type Params = { dbId?: unknown; [name: string]: unknown }; +let answer: (cypher: string, params: Params) => unknown[] = () => []; +const asked: string[] = []; vi.mock('./graph.ts', () => ({ - read: async (cypher, params) => { + read: async (cypher: string, params: Params) => { asked.push(cypher); return answer(cypher, params); }, @@ -24,6 +28,18 @@ const { app } = await import('./server.ts'); const { schemaDataSet } = await import('./data-query-endpoints.ts'); const { lazyCollectionQuery } = await import('./objects.ts'); +/** The lazy loads schemaDataSet asks for. */ +type Lazy = Parameters[2]; +/** The Dataset as these tests read it; what NON_EMPTY leaves out is simply absent. */ +type DataSet = { + version?: unknown; + creator: Record[]; + citation: string[]; + distribution: { fileFormat: string; contentUrl: string }[]; +}; +const build = async (...args: Parameters): Promise => + (await schemaDataSet(...args)) as unknown as DataSet; + describe('schemaDataSet', () => { const event = { schemaClass: 'Pathway', @@ -41,16 +57,16 @@ describe('schemaDataSet', () => { }; // What graph-core's lazy loading finds: each edit's authors (one listed // twice: stoichiometry 2), each person's affiliations. - const related = { + const related: Record>> = { 'author 10': [[{ dbId: 20, firstname: 'Ann', surname: 'Lee', orcidId: '0000-1' }, 2]], 'author 11': [[{ dbId: 21, surname: 'Roe' }, 1]], 'affiliation 20': [[{ dbId: 30, displayName: 'An institute' }, 1]], 'affiliation 21': [], }; - const lazy = async (dbId, type) => related[`${type} ${dbId}`] ?? []; + const lazy: Lazy = async (dbId, type) => related[`${type} ${dbId}`] ?? []; it("writes graph-core's Dataset, in Jackson's order", async () => { - const json = await schemaDataSet(event, 97, lazy); + const json = await build(event, 97, lazy); expect(Object.keys(json)).toEqual([ 'name', 'description', @@ -83,7 +99,7 @@ describe('schemaDataSet', () => { }); it('lists every author of every edit, as often as the edit names them, with their affiliations', async () => { - const { creator } = await schemaDataSet(event, 97, lazy); + const { creator } = await build(event, 97, lazy); const first = { givenName: 'Ann', familyName: 'Lee', @@ -96,11 +112,11 @@ describe('schemaDataSet', () => { first, { familyName: 'Roe', url: 'http://europepmc.org/authors/null', '@type': 'Person' }, ]); - expect(Object.keys(creator[0])).toEqual(Object.keys(first)); + expect(Object.keys(creator[0] ?? {})).toEqual(Object.keys(first)); }); it('offers the seven downloads by dbId, and cites each reference that has an address', async () => { - const { distribution, citation } = await schemaDataSet(event, 97, lazy); + const { distribution, citation } = await build(event, 97, lazy); expect(distribution.map((d) => [d.fileFormat, d.contentUrl])).toEqual([ ['SBML', 'https://reactome.org/ContentService/exporter/sbml/109581.xml'], ['SBGN', 'https://reactome.org/ReactomeRESTfulAPI/RESTfulWS/sbgnExporter/109581'], @@ -110,7 +126,7 @@ describe('schemaDataSet', () => { ['DOCX', 'https://reactome.org/cgi-bin/rtfexporter?DB=gk_current&ID=109581'], ['OWL', 'https://reactome.org/cgi-bin/protegeexporter?DB=gk_current&ID=109581'], ]); - expect(Object.keys(distribution[0])).toEqual([ + expect(Object.keys(distribution[0] ?? {})).toEqual([ 'contentUrl', 'fileFormat', 'encodingFormat', @@ -124,7 +140,7 @@ describe('schemaDataSet', () => { schemaClass: 'Reaction', properties: { dbId: 1, stId: 'R-HSA-1', displayName: 'r' }, }; - const json = await schemaDataSet(bare, null, async () => []); + const json = await build(bare, null, async () => []); expect(['description' in json, json.version, 'creator' in json, 'citation' in json]).toEqual([ false, 'null', @@ -132,7 +148,7 @@ describe('schemaDataSet', () => { false, ]); // And an empty text or name, as Jackson's NON_EMPTY drops "". - const blank = await schemaDataSet( + const blank = await build( { ...bare, properties: { ...bare.properties, displayName: '' }, @@ -140,10 +156,10 @@ describe('schemaDataSet', () => { authored: [1], }, 1, - async (dbId, field) => + async (_dbId, field) => field === 'author' ? [[{ dbId: 2, firstname: '', surname: 'S' }, 1]] : [] ); - expect(['name' in blank, 'description' in blank, Object.keys(blank.creator[0])]).toEqual([ + expect(['name' in blank, 'description' in blank, Object.keys(blank.creator[0] ?? {})]).toEqual([ false, false, ['familyName', 'url', '@type'], @@ -162,15 +178,15 @@ describe('lazyCollectionQuery', () => { }); }); -let base; -let server; +let base: string; +let server: Server; beforeAll(async () => { - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); -afterAll(() => new Promise((resolve) => server.close(resolve))); +afterAll(() => new Promise((resolve) => server.close(() => resolve()))); // A pathway with a summation, a book and one authoring edit, as graph-core's // findByStId returns it, and the lazy loads behind the edit. @@ -205,8 +221,8 @@ const LOADED = { ], }; const graph = - ({ load = [LOADED] } = {}) => - (cypher, params) => { + ({ load = [LOADED] }: { load?: (typeof LOADED)[] } = {}) => + (cypher: string, params: Params) => { // The object's class, and whether it has any relationship, asked first. if (/AS related/.test(cypher)) return [{ labels: (load[0] ?? LOADED).root.labels, related: load.length > 0 }]; @@ -238,7 +254,9 @@ describe('/data/discover/{identifier}', () => { it('is not found when graph-core loads nothing: an object with no relationship, whatever its class', async () => { answer = graph({ load: [] }); const response = await fetch(`${base}/ContentService/data/discover/12345`); - expect([response.status, (await response.json()).messages]).toEqual([404, ['12345 not found']]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [404, ['12345 not found']] + ); }); }); @@ -246,7 +264,9 @@ describe("/data/discover/{identifier}: Java's errors", () => { it('is a 404 naming the id as given, for one not found', async () => { answer = () => []; const response = await fetch(`${base}/ContentService/data/discover/xyz`); - expect([response.status, (await response.json()).messages]).toEqual([404, ['xyz not found']]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [404, ['xyz not found']] + ); }); it('is a 422 with a null message for an object that is not an event', async () => { @@ -262,7 +282,9 @@ describe("/data/discover/{identifier}: Java's errors", () => { ], }); const response = await fetch(`${base}/ContentService/data/discover/R-HSA-199420`); - expect([response.status, (await response.json()).messages]).toEqual([422, [null]]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [422, [null]] + ); }); it('never loads what is not found, or not an event: only an event is worth its relationships', async () => { @@ -270,7 +292,7 @@ describe("/data/discover/{identifier}: Java's errors", () => { for (const [load, status] of [ [[], 404], [[{ ...LOADED, root: { ...ROOT, labels: ['DatabaseObject', 'Species'] } }], 422], - ]) { + ] as [(typeof LOADED)[], number][]) { answer = graph({ load }); asked.length = 0; expect((await fetch(`${base}/ContentService/data/discover/48887`)).status).toBe(status); diff --git a/tools/content-node/flag.spec.mjs b/tools/content-node/flag.spec.ts similarity index 69% rename from tools/content-node/flag.spec.mjs rename to tools/content-node/flag.spec.ts index 392cf78b..22d93246 100644 --- a/tools/content-node/flag.spec.mjs +++ b/tools/content-node/flag.spec.ts @@ -4,12 +4,14 @@ // for the graph and Solr: what content-service's SearchManager asks Solr, // how it folds the answer, and Java's errors. Measured 16 Oct 2026. import { URLSearchParams } from 'node:url'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { describe, expect, it, beforeAll, afterAll, vi } from 'vitest'; -const objects = { +const objects: Record = { 'R-HSA-446203': { labels: ['Pathway', 'Event', 'DatabaseObject'], hasDiagram: true }, }; -const species = { +const species: Record = { Twice: [ { dbId: 1, displayName: 'Twice', abbreviation: 'TWA' }, { dbId: 2, displayName: 'Twice', abbreviation: 'TWB' }, @@ -19,7 +21,7 @@ const species = { }; vi.mock('./graph.ts', () => ({ - read: async (cypher, params) => { + read: async (cypher: string, params: { name: string; id: string }) => { // 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')) @@ -32,55 +34,67 @@ vi.mock('./graph.ts', () => ({ }, configured: () => true, close: async () => {}, - envSetting: (key, fallback = '') => process.env[key] || fallback, + envSetting: (key: string, fallback = '') => process.env[key] || fallback, })); const { app } = await import('./server.ts'); const { JavaHashMap } = await import('./java-hashmap.ts'); -let base; -let server; -let solrServer; -let asked; -let answer; +let base: string; +let server: Server; +/** A form body as the service sent it to Solr: name and value, in order. */ +type Sent = [string, string][]; +let solrServer: Server; +/** What the stand-in Solr was asked: the path, and the form it was sent. */ +let asked: [string, Sent][]; +/** The stand-in Solr's answer to a request. */ +let answer: (sent: Sent) => unknown; beforeAll(async () => { const { createServer } = await import('node:http'); solrServer = createServer((request, response) => { let body = ''; - request.on('data', (chunk) => (body += chunk)); + request.on('data', (chunk: Buffer) => (body += chunk.toString())); request.on('end', () => { const sent = [...new URLSearchParams(body)]; - asked.push([request.url.replace('/solr/reactome', ''), sent]); + const url = request.url ?? ''; + asked.push([url.replace('/solr/reactome', ''), sent]); response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify(answer(sent))); }); }); - await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); - process.env.SOLR_URL = `http://127.0.0.1:${solrServer.address().port}/solr/reactome`; - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); + process.env['SOLR_URL'] = + `http://127.0.0.1:${(solrServer.address() as AddressInfo).port}/solr/reactome`; + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(async () => { - delete process.env.SOLR_URL; - await new Promise((resolve) => server.close(resolve)); - await new Promise((resolve) => solrServer.close(resolve)); + delete process.env['SOLR_URL']; + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => solrServer.close(() => resolve())); }); -const get = (path) => fetch(`${base}/ContentService/search/${path}`); +/** The nth request the stand-in Solr was asked; one not asked is the test's failure. */ +const request = (at: number) => { + const found = asked[at]; + if (!found) throw new Error(`Solr was not asked a request ${at}`); + return found; +}; +const get = (path: string) => fetch(`${base}/ContentService/search/${path}`); /** A java.util.HashSet's order for these keys, added in this order. */ -const hashSet = (keys) => { - const map = new JavaHashMap(); +const hashSet = (keys: string[]) => { + const map = new JavaHashMap(); for (const key of keys) map.set(key, true); return [...map.keys()]; }; describe('JavaHashMap.delete, as HashSet.remove', () => { it('unlinks a key and keeps the rest in order', () => { - const map = new JavaHashMap(); + const map = new JavaHashMap(); for (const key of ['q', 'a', 'b']) map.set(key, true); map.delete('q'); map.delete('missing'); @@ -90,13 +104,13 @@ describe('JavaHashMap.delete, as HashSet.remove', () => { it('refuses to remove from a tree bin, whose rebalancing it does not port, rather than corrupt it', () => { // Strings of "Aa" and "BB" share a hash; sixteen of them, past 64 buckets, make a tree bin. - const map = new JavaHashMap(); + const map = new JavaHashMap(); for (let at = 0; at < 60; at += 1) map.set(`filler${at}`, true); - const colliding = []; + const colliding: string[] = []; for (let bits = 0; bits < 16; bits += 1) colliding.push([0, 1, 2, 3].map((bit) => (bits & (1 << bit) ? 'BB' : 'Aa')).join('')); for (const key of colliding) map.set(key, true); - expect(() => map.delete(colliding[3])).toThrow(/tree bin/); + expect(() => map.delete(colliding[3] ?? '')).toThrow(/tree bin/); expect(map.delete('filler7')).toBe(true); }); }); @@ -141,21 +155,19 @@ describe('/diagram/{pathwayId}/flag', () => { 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'], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).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: [] } }); const response = await get('diagram/R-HSA-446203/flag?query=zzz&includeInteractors=true'); - expect([response.status, (await response.json()).messages]).toEqual([ - 404, - ["No entities with identifier 'zzz' found for R-HSA-446203"], - ]); - expect(asked[0][1].find(([name]) => name === 'q')).toEqual([ + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [404, ["No entities with identifier 'zzz' found for R-HSA-446203"]] + ); + expect(request(0)[1].find(([name]) => name === 'q')).toEqual([ 'q', '"zzz" AND occurrencesWithInteractor:R-HSA-446203*', ]); @@ -164,10 +176,13 @@ describe('/diagram/{pathwayId}/flag', () => { it("checks the pathway first, then a blank includeInteractors is Java's 500", async () => { asked = []; const bad = await get('diagram/xyz/flag?query=CTSA&includeInteractors=%20'); - expect([bad.status, (await bad.json()).messages]).toEqual([400, ["'xyz' cannot be found"]]); + expect([bad.status, ((await bad.json()) as { messages: string[] }).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([ + expect(((await blank.json()) as { messages: string[] }).messages).toEqual([ 'Something unexpected happened and the error has been reported.', ]); expect(asked).toHaveLength(0); @@ -219,22 +234,23 @@ describe('/fireworks/flag', () => { it("is Java's 400 for a species no Species is named, matched exactly", async () => { asked = []; const response = await get('fireworks/flag?query=PTEN&species=homo%20sapiens'); - expect([response.status, (await response.json()).messages]).toEqual([ - 400, - ["No species found for 'homo sapiens'"], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [400, ["No species found for 'homo sapiens'"]] + ); expect(asked).toHaveLength(0); }); it("is the 404 naming the Species as Java's toString writes it", async () => { answer = () => ({ response: { numFound: 1, docs: [{ llps: ['R-HSA-1'] }] } }); const response = await get('fireworks/flag?query=x&species=Mus%20musculus'); - expect([response.status, (await response.json()).messages]).toEqual([ - 404, + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( [ - "No entries found for query: 'x' in species 'Species {dbId=48892, displayName='Mus musculus'}'", - ], - ]); + 404, + [ + "No entries found for query: 'x' in species 'Species {dbId=48892, displayName='Mus musculus'}'", + ], + ] + ); }); it('is a failure, before Solr is asked, when two Species share the name', async () => { @@ -252,9 +268,8 @@ describe('/fireworks/flag', () => { 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'], - ]); + expect([response.status, ((await response.json()) as { messages: string[] }).messages]).toEqual( + [500, ['Index 3 out of bounds for length 1']] + ); }); }); diff --git a/tools/content-node/pathways-of.spec.mjs b/tools/content-node/pathways-of.spec.ts similarity index 67% rename from tools/content-node/pathways-of.spec.mjs rename to tools/content-node/pathways-of.spec.ts index de6201c2..e5398730 100644 --- a/tools/content-node/pathways-of.spec.mjs +++ b/tools/content-node/pathways-of.spec.ts @@ -4,42 +4,56 @@ // dbId, then its diagrams' documents by stable id, and Spring's reading of // the parameters. Java's answers measured 16 Oct 2026. import { URLSearchParams } from 'node:url'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { describe, expect, it, beforeAll, afterAll } from 'vitest'; import { app } from './server.ts'; -let base; -let server; -let solrServer; -let asked; -let answer; +let base: string; +let server: Server; +/** A form body as the service sent it to Solr: name and value, in order. */ +type Sent = [string, string][]; +let solrServer: Server; +/** What the stand-in Solr was asked: the path, and the form it was sent. */ +let asked: [string, Sent][]; +/** The stand-in Solr's answer to a request. */ +let answer: (url: string, sent: Sent) => unknown; beforeAll(async () => { const { createServer } = await import('node:http'); solrServer = createServer((request, response) => { let body = ''; - request.on('data', (chunk) => (body += chunk)); + request.on('data', (chunk: Buffer) => (body += chunk.toString())); request.on('end', () => { const sent = [...new URLSearchParams(body)]; - asked.push([request.url.replace('/solr/reactome', ''), sent]); + const url = request.url ?? ''; + asked.push([url.replace('/solr/reactome', ''), sent]); response.setHeader('Content-Type', 'application/json'); - response.end(JSON.stringify(answer(request.url, sent))); + response.end(JSON.stringify(answer(url, sent))); }); }); - await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); - process.env.SOLR_URL = `http://127.0.0.1:${solrServer.address().port}/solr/reactome`; - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve)); + process.env['SOLR_URL'] = + `http://127.0.0.1:${(solrServer.address() as AddressInfo).port}/solr/reactome`; + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); afterAll(async () => { - delete process.env.SOLR_URL; - await new Promise((resolve) => server.close(resolve)); - await new Promise((resolve) => solrServer.close(resolve)); + delete process.env['SOLR_URL']; + await new Promise((resolve) => server.close(() => resolve())); + await new Promise((resolve) => solrServer.close(() => resolve())); }); -const get = (path) => fetch(`${base}/ContentService/search/pathways/of/${path}`); +/** The nth request the stand-in Solr was asked; one not asked is the test's failure. */ +const request = (at: number) => { + const found = asked[at]; + if (!found) throw new Error(`Solr was not asked a request ${at}`); + return found; +}; +const get = (path: string) => fetch(`${base}/ContentService/search/pathways/of/${path}`); const entity = { occurrences: ['R-HSA-196849:false:R-HSA-196757:#', 'R-HSA-196757:true:#:#'], }; @@ -47,7 +61,7 @@ const pathways = [ { dbId: '196849', stId: 'R-HSA-196849', name: 'Metabolism of water-soluble vitamins' }, { dbId: '196757', stId: 'R-HSA-196757', name: 'Metabolism of folate', hasEHLD: false }, ]; -const solr = (url) => +const solr = (url: string) => url.endsWith('/get') ? { response: { numFound: 1, docs: [entity] } } : { response: { numFound: pathways.length, docs: pathways } }; @@ -89,16 +103,16 @@ describe('/pathways/of/{dbId}', () => { it('keeps only the diagrams it is directly in, through interactors when asked', async () => { asked = []; - answer = (url) => + answer = (url: string) => url.endsWith('/get') ? { response: { numFound: 1, docs: [{ occurrencesWithInteractor: entity.occurrences }] } } : { response: { numFound: 1, docs: [pathways[1]] } }; await get('227785?directlyInDiagram=true&includeInteractors=true'); - expect(asked[0][1].find(([name]) => name === 'fl')).toEqual([ + expect(request(0)[1].find(([name]) => name === 'fl')).toEqual([ 'fl', 'occurrencesWithInteractor', ]); - expect(asked[1][1]).toEqual([ + expect(request(1)[1]).toEqual([ ['wt', 'json'], ['json.nl', 'flat'], ['q', '{!terms f=stId}r-hsa-196757'], @@ -108,20 +122,20 @@ describe('/pathways/of/{dbId}', () => { it('is the 404 when the entity has no document, or no diagrams to show', async () => { for (const docs of [[], [{}]]) { - answer = (url) => + answer = (url: string) => url.endsWith('/get') ? { response: { numFound: docs.length, docs } } : { response: { numFound: 0, docs: [] } }; const response = await get('1'); - expect([response.status, (await response.json()).messages]).toEqual([ - 404, - ['No pathways found for dbId: 1'], - ]); + expect([ + response.status, + ((await response.json()) as { messages: string[] }).messages, + ]).toEqual([404, ['No pathways found for dbId: 1']]); } }); it("is Spring's 400 for a field that is no Field, naming it as sent", async () => { - const message = (type, sent, name) => + const message = (type: string, sent: string, name: string) => `Failed to convert value of type '${type}' to required type 'java.util.List'; nested exception is ` + 'org.springframework.core.convert.ConversionFailedException: Failed to convert from type [java.lang.String] ' + 'to type [@io.swagger.v3.oas.annotations.Parameter @org.springframework.web.bind.annotation.RequestParam ' + @@ -131,26 +145,26 @@ describe('/pathways/of/{dbId}', () => { for (const [query, type] of [ ['fields=%20stId', 'java.lang.String'], ['fields=%20stId&fields=ST_ID', 'java.lang.String[]'], - ]) { + ] as const) { const response = await get(`227785?${query}`); - expect([query, response.status, (await response.json()).messages]).toEqual([ + expect([ query, - 400, - [message(type, ' stId', 'stId')], - ]); + response.status, + ((await response.json()) as { messages: string[] }).messages, + ]).toEqual([query, 400, [message(type, ' stId', 'stId')]]); } expect(asked).toHaveLength(0); }); it("is Spring's 400 for a dbId that is no Long, and Java's 500 for an empty field among several", async () => { const bad = await get('abc'); - expect((await bad.json()).messages).toEqual([ + expect(((await bad.json()) as { messages: string[] }).messages).toEqual([ `Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; nested exception is java.lang.NumberFormatException: For input string: "abc"`, ]); // Only once the entity's document is found does Java name the fields. answer = solr; const empty = await get('227785?fields=&fields=ST_ID'); - expect([empty.status, (await empty.json()).messages]).toEqual([ + expect([empty.status, ((await empty.json()) as { messages: string[] }).messages]).toEqual([ 500, ['Something unexpected happened and the error has been reported.'], ]); @@ -162,22 +176,22 @@ describe('/pathways/of/{dbId}', () => { asked = []; answer = solr; const interactors = await get('227785?includeInteractors=%20'); - expect([interactors.status, (await interactors.json()).messages, asked.length]).toEqual([ - 500, - unexpected, - 0, - ]); + expect([ + interactors.status, + ((await interactors.json()) as { messages: string[] }).messages, + asked.length, + ]).toEqual([500, unexpected, 0]); // directlyInDiagram and a blank field only once the document is found. for (const query of ['directlyInDiagram=%20', 'fields=%20,ST_ID', 'fields=%20&fields=ST_ID']) { asked = []; answer = solr; const found = await get(`227785?${query}`); - expect([query, found.status, (await found.json()).messages, asked.length]).toEqual([ + expect([ query, - 500, - unexpected, - 1, - ]); + found.status, + ((await found.json()) as { messages: string[] }).messages, + asked.length, + ]).toEqual([query, 500, unexpected, 1]); answer = () => ({ response: { numFound: 0, docs: [] } }); const missing = await get(`227785?${query}`); expect([query, missing.status]).toEqual([query, 404]); diff --git a/tools/content-node/query-attribute.spec.mjs b/tools/content-node/query-attribute.spec.ts similarity index 63% rename from tools/content-node/query-attribute.spec.mjs rename to tools/content-node/query-attribute.spec.ts index 8a9c985e..cb2cc0b8 100644 --- a/tools/content-node/query-attribute.spec.mjs +++ b/tools/content-node/query-attribute.spec.ts @@ -5,10 +5,35 @@ // ControllerUtils.toTSV writes. The objects here are invented; the formats // were measured against Java on 18 Oct 2026. import { describe, expect, it } from 'vitest'; -import { attributeGetter, attributeText, attributeValue, mapNodes } from './domain.ts'; +import { + attributeGetter, + attributeText, + attributeValue, + mapNodes, + type DomainEntity, +} from './domain.ts'; +import type { GraphNode } from './graph.ts'; -const node = (id, labels, props) => ({ id, labels: ['DatabaseObject', ...labels], props }); -const [pathway] = mapNodes([ +const node = (id: number, labels: string[], props: GraphNode['props']): GraphNode => ({ + id, + labels: ['DatabaseObject', ...labels], + props, +}); +/** The nodes, mapped together; one not mapped is the test's failure, not undefined. */ +const mapped = (nodes: [...T]) => { + const entities = mapNodes(nodes); + if (entities.length !== nodes.length) throw new Error('not every node was mapped'); + return entities as { [K in keyof T]: DomainEntity }; +}; +/** A getter that exists; a missing one is the test's failure, not undefined. */ +const getter = (schemaClass: string, attribute: string) => { + const found = attributeGetter(schemaClass, attribute); + if (!found) throw new Error(`${schemaClass} has no getter for ${attribute}`); + return found; +}; +/** What loads a relationship, as attributeValue asks for one. */ +type LazyLoad = Parameters[2]; +const [pathway] = mapped([ node(1, ['Event', 'Pathway'], { dbId: 109581, stId: 'R-HSA-109581', @@ -22,13 +47,14 @@ const [pathway] = mapNodes([ const noLoads = async () => { throw new Error('nothing should be loaded'); }; -const text = (value, getter) => attributeText(value, (html) => `jsoup(${html})`, getter); +const text = (value: unknown, returned?: { returns: string }) => + attributeText(value, (html) => `jsoup(${html})`, returned); describe('attributeGetter', () => { it('finds the getter by name, ignoring case, as getProperty does', () => { - expect(attributeGetter('Pathway', 'DISPLAYNAME').name).toBe('getDisplayName'); - expect(attributeGetter('Pathway', 'hasdiagram').name).toBe('getHasDiagram'); - expect(attributeGetter('Pathway', 'class').name).toBe('getClass'); + expect(attributeGetter('Pathway', 'DISPLAYNAME')?.name).toBe('getDisplayName'); + expect(attributeGetter('Pathway', 'hasdiagram')?.name).toBe('getHasDiagram'); + expect(attributeGetter('Pathway', 'class')?.name).toBe('getClass'); expect(attributeGetter('Pathway', 'getClass')).toBe(undefined); expect(attributeGetter('Pathway', 'xyz')).toBe(undefined); }); @@ -36,10 +62,10 @@ describe('attributeGetter', () => { describe('attributeValue and attributeText', () => { it("writes values as Java's toString does, a list one item a line", async () => { - const value = async (attribute) => + const value = async (attribute: string) => text( - await attributeValue(pathway, attributeGetter('Pathway', attribute), noLoads), - attributeGetter('Pathway', attribute) + await attributeValue(pathway, getter('Pathway', attribute), noLoads), + getter('Pathway', attribute) ); expect(await value('displayName')).toBe('Apoptosis'); expect(await value('dbId')).toBe('109581'); @@ -53,7 +79,7 @@ describe('attributeValue and attributeText', () => { }); it('writes an object as its id, text and class; a summation through Jsoup', () => { - const [species, summation] = mapNodes([ + const [species, summation] = mapped([ node(2, ['Taxon', 'Species'], { dbId: 48887, displayName: 'Homo sapiens', @@ -72,7 +98,7 @@ describe('attributeValue and attributeText', () => { }); it('loads an empty incoming relationship, as the aspect does, through its setter', async () => { - const [entity] = mapNodes([ + const [entity] = mapped([ node(4, ['PhysicalEntity', 'Complex'], { dbId: 5, stId: 'R-HSA-5', @@ -80,7 +106,7 @@ describe('attributeValue and attributeText', () => { schemaClass: 'Complex', }), ]); - const [a, b] = mapNodes([ + const [a, b] = mapped([ node(5, ['PhysicalEntity', 'Complex'], { dbId: 6, stId: 'R-HSA-6', @@ -94,35 +120,29 @@ describe('attributeValue and attributeText', () => { schemaClass: 'Complex', }), ]); - const asked = []; - const load = async (dbId, type, direction, getter) => { - asked.push([dbId, type, direction, getter.element]); + const asked: unknown[][] = []; + const load: LazyLoad = async (dbId, type, direction, wanted) => { + asked.push([dbId, type, direction, wanted.element]); // A twice: a relationship of stoichiometry 2. return [a, a, b]; }; // The projected getter calls getComponentOf, which loads; its relations // then carry the order met and the times met. - const value = await attributeValue( - entity, - attributeGetter('Complex', 'componentOfComplexes'), - load - ); + const value = await attributeValue(entity, getter('Complex', 'componentOfComplexes'), load); expect(asked).toEqual([[5, 'hasComponent', 'INCOMING', 'Complex']]); expect(text(value)).toBe( "Has{type=componentOf, stoichiometry=2, order=0, element=Complex {dbId=6, stId='R-HSA-6', displayName='A'}}\n" + "Has{type=componentOf, stoichiometry=1, order=1, element=Complex {dbId=7, stId='R-HSA-7', displayName='B'}}\n" ); // The plain getter expands them again, and loads nothing more. - const expanded = await attributeValue( - entity, - attributeGetter('Complex', 'componentOf'), - noLoads - ); - expect(expanded.map((complex) => complex.properties.dbId)).toEqual([6, 6, 7]); + const expanded = await attributeValue(entity, getter('Complex', 'componentOf'), noLoads); + expect((expanded as DomainEntity[]).map((complex) => complex.properties['dbId'])).toEqual([ + 6, 6, 7, + ]); }); it('never fetches an outgoing relationship again: findById brought it, even empty', async () => { - const value = await attributeValue(pathway, attributeGetter('Pathway', 'hasEvent'), noLoads); + const value = await attributeValue(pathway, getter('Pathway', 'hasEvent'), noLoads); expect(text(value ?? [])).toBe(''); }); @@ -130,22 +150,22 @@ describe('attributeValue and attributeText', () => { expect(text(2, { returns: 'Double' })).toBe('2.0'); expect(text(0.00001, { returns: 'Double' })).toBe('1.0E-5'); expect(text(2, { returns: 'Integer' })).toBe('2'); - const [unnamed] = mapNodes([ + const [unnamed] = mapped([ node(9, ['Taxon', 'Species'], { dbId: 9, stId: '', schemaClass: 'Species' }), ]); expect(text(unnamed)).toBe('9\tnull\tSpecies'); }); it('writes a relationship whose element has no stable id or name as Has.toString does', async () => { - const [complex] = mapNodes([ + const [complex] = mapped([ node(10, ['PhysicalEntity', 'Complex'], { dbId: 10, schemaClass: 'Complex' }), ]); - const [part] = mapNodes([ + const [part] = mapped([ node(11, ['PhysicalEntity', 'SimpleEntity'], { dbId: 11, schemaClass: 'SimpleEntity' }), ]); const value = await attributeValue( complex, - attributeGetter('Complex', 'componentOfComplexes'), + getter('Complex', 'componentOfComplexes'), async () => [part] ); expect(text(value)).toBe( @@ -156,26 +176,24 @@ describe('attributeValue and attributeText', () => { describe('the getters graph-core computes', () => { it("Person's created and modified are DatabaseObject's own", async () => { - const [person] = mapNodes([node(12, ['Person'], { dbId: 12, schemaClass: 'Person' })]); - const [created, modified] = mapNodes([ + const [person] = mapped([node(12, ['Person'], { dbId: 12, schemaClass: 'Person' })]); + const [created, modified] = mapped([ node(13, ['InstanceEdit'], { dbId: 13, displayName: 'c', schemaClass: 'InstanceEdit' }), node(14, ['InstanceEdit'], { dbId: 14, displayName: 'm', schemaClass: 'InstanceEdit' }), ]); - person.fields.created = created; - person.fields.modified = modified; - expect(text(await attributeValue(person, attributeGetter('Person', 'created'), noLoads))).toBe( + person.fields['created'] = created; + person.fields['modified'] = modified; + expect(text(await attributeValue(person, getter('Person', 'created'), noLoads))).toBe( '13\tc\tInstanceEdit' ); - expect(text(await attributeValue(person, attributeGetter('Person', 'modified'), noLoads))).toBe( + expect(text(await attributeValue(person, getter('Person', 'modified'), noLoads))).toBe( '14\tm\tInstanceEdit' ); }); it("a pathway's encapsulated events, each as often as its stoichiometry", async () => { - const [outer] = mapNodes([ - node(15, ['Event', 'Pathway'], { dbId: 15, schemaClass: 'Pathway' }), - ]); - const [inner] = mapNodes([ + const [outer] = mapped([node(15, ['Event', 'Pathway'], { dbId: 15, schemaClass: 'Pathway' })]); + const [inner] = mapped([ node(16, ['Event', 'Pathway'], { dbId: 16, stId: 'R-HSA-16', @@ -183,17 +201,13 @@ describe('the getters graph-core computes', () => { schemaClass: 'Pathway', }), ]); - outer.fields.hasEncapsulatedEvent = [{ element: inner, stoichiometry: 2, order: 0 }]; - const value = await attributeValue( - outer, - attributeGetter('Pathway', 'hasEncapsulatedEvent'), - noLoads - ); + outer.fields['hasEncapsulatedEvent'] = [{ element: inner, stoichiometry: 2, order: 0 }]; + const value = await attributeValue(outer, getter('Pathway', 'hasEncapsulatedEvent'), noLoads); expect(text(value)).toBe('R-HSA-16\ti\tPathway\nR-HSA-16\ti\tPathway\n'); }); it("a reference entity's database name with its spaces as dashes; none at all is Java's NullPointerException", async () => { - const [entity] = mapNodes([ + const [entity] = mapped([ node(17, ['ReferenceEntity', 'ReferenceGeneProduct'], { dbId: 17, databaseName: 'ENSEMBL Fungi x', @@ -203,22 +217,18 @@ describe('the getters graph-core computes', () => { expect( await attributeValue( entity, - attributeGetter('ReferenceGeneProduct', 'simplifiedDatabaseName'), + getter('ReferenceGeneProduct', 'simplifiedDatabaseName'), noLoads ) ).toBe('ENSEMBL-Fungi-x'); - const [none] = mapNodes([ + const [none] = mapped([ node(18, ['ReferenceEntity', 'ReferenceGeneProduct'], { dbId: 18, schemaClass: 'ReferenceGeneProduct', }), ]); await expect( - attributeValue( - none, - attributeGetter('ReferenceGeneProduct', 'simplifiedDatabaseName'), - noLoads - ) + attributeValue(none, getter('ReferenceGeneProduct', 'simplifiedDatabaseName'), noLoads) ).rejects.toThrow(/null/); }); }); diff --git a/tools/content-node/references-mapping.spec.mjs b/tools/content-node/references-mapping.spec.ts similarity index 83% rename from tools/content-node/references-mapping.spec.mjs rename to tools/content-node/references-mapping.spec.ts index 8f612f2a..a3f24844 100644 --- a/tools/content-node/references-mapping.spec.mjs +++ b/tools/content-node/references-mapping.spec.ts @@ -7,12 +7,18 @@ // identifier and filter passed as parameters where Java writes them into the // text, and graph-core's ReferenceEntityRepository. The entities here are invented; Java's answers // were measured 17 Oct 2026. +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -let answer = () => []; -const asked = []; +/** A query's parameters, as the service passes them. */ +type Params = { identifier?: unknown; dbFilter?: unknown; [name: string]: unknown }; +/** The stand-in graph's rows for a query, or a promise of them. */ +let answer: (cypher: string, params: Params) => unknown = () => []; +/** Each query the stand-in graph was asked, with its parameters. */ +const asked: [string, Params][] = []; vi.mock('./graph.ts', () => ({ - read: async (cypher, params) => { + read: async (cypher: string, params: Params) => { asked.push([cypher, params]); return answer(cypher, params); }, @@ -24,25 +30,34 @@ vi.mock('./graph.ts', () => ({ const { app } = await import('./server.ts'); const { crossReferenceQuery, pageJson, pageText } = await import('./mapping-species-endpoints.ts'); -let base; -let server; +let base: string; +let server: Server; beforeAll(async () => { - await new Promise((resolve) => { - server = app().listen(0, '127.0.0.1', resolve); + await new Promise((resolve) => { + server = app().listen(0, '127.0.0.1', () => resolve()); }); - base = `http://127.0.0.1:${server.address().port}`; + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; }); -afterAll(() => new Promise((resolve) => server.close(resolve))); +afterAll(() => new Promise((resolve) => server.close(() => resolve()))); -const get = (path) => fetch(`${base}/ContentService/references/mapping/${path}`); -const post = (query, body, type = 'text/plain') => +const get = (path: string) => fetch(`${base}/ContentService/references/mapping/${path}`); +const post = (query: string, body: string | undefined, type = 'text/plain') => fetch(`${base}/ContentService/references/mapping/xrefs${query}`, { method: 'POST', headers: type ? { 'content-type': type } : {}, - body, + ...(body === undefined ? {} : { body }), }); -const messages = async (response) => [response.status, (await response.json()).messages]; -const last = () => asked[asked.length - 1]; +/** Status and the messages of Java's error envelope. */ +const messages = async (response: Response): Promise<[number, string[]]> => [ + response.status, + ((await response.json()) as { messages: string[] }).messages, +]; +/** The last query asked; none at all is the test's failure, not undefined. */ +const last = () => { + const query = asked.at(-1); + if (!query) throw new Error('the graph was asked nothing'); + return query; +}; describe('crossReferenceQuery', () => { it("is the controller's text, the identifier and filter as parameters", () => { @@ -83,7 +98,7 @@ describe('/references/mapping/{identifier}', () => { ]; asked.length = 0; const response = await get('P1'); - expect((await response.json()).map((e) => e.dbId)).toEqual([1]); + expect(((await response.json()) as { dbId: number }[]).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, labels\(n\) AS labels$/ ); @@ -173,7 +188,7 @@ describe('/references/mapping/{identifier}/xrefs', () => { const response = await get('P36%22897/xrefs?dbFilter=D%22'); expect(response.status).toBe(404); expect(asked).toEqual([[crossReferenceQuery('D"'), { identifier: 'P36"897', dbFilter: 'D"' }]]); - expect(asked[0][0]).not.toContain('P36'); + expect(last()[0]).not.toContain('P36'); }); it("is the service's 503 when the database ends the query, as everywhere", async () => { @@ -184,15 +199,20 @@ describe('/references/mapping/{identifier}/xrefs', () => { }; const response = await get('P1/xrefs'); expect(response.status).toBe(503); - expect((await response.json()).reason).toBe('SERVICE_UNAVAILABLE'); + expect(((await response.json()) as { reason: string }).reason).toBe('SERVICE_UNAVAILABLE'); }); }); describe('POST /references/mapping/xrefs', () => { it('pages the identifiers, each to its results or NO MATCH', async () => { - answer = (cypher, params) => (params.identifier === 'P1' ? [XREF_ROW] : []); + answer = (_cypher, params) => (params.identifier === 'P1' ? [XREF_ROW] : []); const response = await post('?page=0&pageSize=2', 'P1, xyz;Q2'); - const json = await response.json(); + const json = (await response.json()) as { + content: unknown[]; + totalElements: number; + totalPages: number; + numberOfElements: number; + }; expect(json.content).toEqual([ { P1: [ @@ -213,7 +233,9 @@ describe('POST /references/mapping/xrefs', () => { it('splits on newline and tab too, and trims as String.trim does', async () => { answer = () => []; - const json = await (await post('', 'A\nB\tC\u0001 ')).json(); + const json = (await (await post('', 'A\nB\tC\u0001 ')).json()) as { + content: Record[]; + }; expect(json.content.map((entry) => Object.keys(entry)[0])).toEqual(['A', 'B', 'C']); }); @@ -236,19 +258,19 @@ describe('POST /references/mapping/xrefs', () => { }); it("is the first failure's answer when one identifier breaks the query", async () => { - answer = (cypher, params) => { + answer = (_cypher, params) => { if (params.identifier === 'B') throw Object.assign(new Error('bad'), { code: 'Neo.ClientError.Statement.SyntaxError' }); return []; }; const [status, [message]] = await messages(await post('', 'A,B,C')); - expect([status, message.startsWith('bad; Error code')]).toEqual([500, true]); + expect([status, message?.startsWith('bad; Error code')]).toEqual([500, true]); }); it('starts no lookup after one throws', async () => { asked.length = 0; // The first lookup fails; the others would each take 5ms. - answer = (cypher, params) => { + answer = (_cypher, params) => { if (params.identifier === 'I0') throw new Error('the database is gone'); return new Promise((resolve) => setTimeout(() => resolve([]), 5)); }; @@ -261,8 +283,8 @@ describe('POST /references/mapping/xrefs', () => { it('starts no lookup once the client has gone', async () => { asked.length = 0; - let started; - const first = new Promise((resolve) => (started = resolve)); + let started: () => void = () => undefined; + const first = new Promise((resolve) => (started = resolve)); answer = () => { started(); return new Promise((resolve) => setTimeout(() => resolve([]), 20)); @@ -286,8 +308,8 @@ describe('POST /references/mapping/xrefs', () => { }); it('runs two batches at once and answers a third 503, Retry-After', async () => { - let release; - const held = new Promise((resolve) => (release = resolve)); + let release: () => void = () => undefined; + const held = new Promise((resolve) => (release = resolve)); answer = () => held.then(() => []); const two = [post('', 'A'), post('', 'B')]; await new Promise((resolve) => setTimeout(resolve, 50));