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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion specs/013-server-types/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown[]> = {};
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',
Expand All @@ -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) : [];
Expand All @@ -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<void>((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<void>((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 () => {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { labels: string[]; hasDiagram: boolean | null }> = {
'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 },
Expand All @@ -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<void>((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<void>((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<void>((resolve) => server.close(() => resolve()));
await new Promise<void>((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',
Expand All @@ -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);
});
Expand All @@ -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 () => {
Expand All @@ -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",
]);
});
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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',
]);
Expand All @@ -268,25 +282,24 @@ 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]]);
}
});

it('asks again in every scope, then is the 404', async () => {
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'], []]);
});
});
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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);
});
});
Loading
Loading