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
4 changes: 2 additions & 2 deletions specs/010-java-services/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,11 @@
- compare status, a fixed header set and the SHA-256 of the body;
- per-class normalisers, declared in `tools/java-parity/normalisers.ts`;
- JSON report of identical, different and both-error counts, with no bodies or token values in the output.
- [x] T007 Add a self-test to `tools/java-parity/parity.spec.mjs`. Two stub servers that differ in one byte must report a difference; identical stubs must not. Show it failing with the comparison disabled. — #507
- [x] T007 Add a self-test to `tools/java-parity/parity.spec.ts`. Two stub servers that differ in one byte must report a difference; identical stubs must not. Show it failing with the comparison disabled. — #507
- [ ] T008 [P] (With the first PDF comparison, T030: it needs `qpdf` in a container.) Add the PDF normaliser to `tools/java-parity/normalisers.ts`:
- `qpdf --qdf --object-streams=disable --static-id`, run in a throwaway container;
- mask CreationDate, ModDate and the cover-page date.
- Test it in `tools/java-parity/parity.spec.mjs`: two PDFs differing only in those fields compare equal, and a PDF with a changed body does not.
- Test it in `tools/java-parity/parity.spec.ts`: two PDFs differing only in those fields compare equal, and a PDF with a changed body does not.

**Checkpoint**: routing tests know three outcomes; the harness detects a one-byte difference.

Expand Down
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. Seven more (contained-events, pathways-of, flag, query-attribute, discover, diagram-search, references-mapping), 235 — #649. icon, facet-query and the Java parity spec, 151 — #650.
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. icon, facet-query and the Java parity spec, 151 — #650. query-search and species-search, 151 — #651.

- [ ] 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 @@ -5,39 +5,47 @@
// search-core's fallback chain, grouped and plain search, paging, and the
// report of a search that finds nothing. Java's answers measured 17 Oct 2026.
import { URLSearchParams } from 'node:url';
import type { RequestListener, Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { describe, expect, it, beforeAll, afterAll, vi } from 'vitest';

vi.mock('./graph.ts', () => ({
read: async () => [{ version: 97 }],
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 { facetQueryParams } = await import('./search-endpoints.ts');

let base;
let server;
let solrServer;
let reportServer;
let asked;
let answer;
let reports;
/** A form or query as the service sent it to Solr: name and value, in order. */
type Sent = [string, string][];
let base: string;
let server: Server;
let solrServer: Server;
let reportServer: Server;
/** What the stand-in Solr was asked: its handler, and what it was sent. */
let asked: [string, Sent][];
/** The stand-in Solr's answer to a request. */
let answer: (handler: string, sent: Sent) => unknown;
/** What reached the stand-in report service. */
let reports: { url: string | undefined; body: string }[];

const listen = async (handler) => {
const listen = async (handler: RequestListener) => {
const { createServer } = await import('node:http');
const listening = createServer(handler);
await new Promise((resolve) => listening.listen(0, '127.0.0.1', resolve));
await new Promise<void>((resolve) => listening.listen(0, '127.0.0.1', resolve));
return listening;
};
const portOf = (listening: Server) => (listening.address() as AddressInfo).port;

beforeAll(async () => {
solrServer = await listen((request, response) => {
let body = '';
request.on('data', (chunk) => (body += chunk));
request.on('data', (chunk: Buffer) => (body += chunk.toString()));
request.on('end', () => {
const [path, query = ''] = request.url.split('?');
const [path = '', query = ''] = (request.url ?? '').split('?');
const sent = [...new URLSearchParams(request.method === 'GET' ? query : body)];
const handler = path.replace('/solr', '');
asked.push([handler, sent]);
Expand All @@ -47,38 +55,49 @@ beforeAll(async () => {
});
reportServer = await listen((request, response) => {
let body = '';
request.on('data', (chunk) => (body += chunk));
request.on('data', (chunk: Buffer) => (body += chunk.toString()));
request.on('end', () => {
reports.push({ url: request.url, body });
response.end();
});
});
process.env.SOLR_URL = `http://127.0.0.1:${solrServer.address().port}/solr/reactome`;
process.env.REPORT_URL = `http://127.0.0.1:${reportServer.address().port}`;
await new Promise((resolve) => {
server = app().listen(0, '127.0.0.1', resolve);
process.env['SOLR_URL'] = `http://127.0.0.1:${portOf(solrServer)}/solr/reactome`;
process.env['REPORT_URL'] = `http://127.0.0.1:${portOf(reportServer)}`;
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:${portOf(server)}`;
});

afterAll(async () => {
for (const name of ['SOLR_URL', 'REPORT_URL']) delete process.env[name];
await new Promise((resolve) => server.close(resolve));
await new Promise((resolve) => solrServer.close(resolve));
await new Promise((resolve) => reportServer.close(resolve));
for (const closing of [server, solrServer, reportServer])
await new Promise<void>((resolve) => closing.close(() => resolve()));
});

const get = (path) => fetch(`${base}/ContentService/search/${path}`);
const until = async (ready) => {
/** The nth request 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;
};
/** The nth report sent; one not sent is the test's failure. */
const report = (at: number) => {
const found = reports[at];
if (!found) throw new Error(`no report ${at} was sent`);
return found;
};
const get = (path: string) => fetch(`${base}/ContentService/search/${path}`);
const until = async (ready: () => boolean) => {
for (let tries = 0; !ready() && tries < 400; tries += 1)
await new Promise((resolve) => setTimeout(resolve, 5));
expect(ready()).toBe(true);
};
const fqs = (sent) => sent.filter(([name]) => name === 'fq').map(([, value]) => value);
const value = (sent, name) => sent.find(([key]) => key === name)?.[1];
const fqs = (sent: Sent) => sent.filter(([name]) => name === 'fq').map(([, value]) => value);
const value = (sent: Sent, name: string) => sent.find(([key]) => key === name)?.[1];

/** Facets that found `found`, with two types. */
const facets = (found) => ({
const facets = (found: number) => ({
response: { numFound: found },
facet_counts: {
facet_fields: {
Expand All @@ -89,14 +108,14 @@ const facets = (found) => ({
},
},
});
const doc = (id, name) => ({
const doc = (id: string, name: string) => ({
dbId: id,
stId: `R-HSA-${id}`,
name,
type: 'Protein',
exactType: 'Protein',
});
const grouped = (groups) => ({
const grouped = (groups: [string | null, object[]][]) => ({
grouped: {
type_facet: {
matches: 8,
Expand All @@ -113,7 +132,13 @@ const grouped = (groups) => ({
describe('facetQueryParams, as the fallback chain changes the query', () => {
it('asks for the deleted entries, and with another parser', () => {
expect(
facetQueryParams({ query: 'x', scope: 'BOTH', deleted: true, parserType: 'DISMAX' })
facetQueryParams({
query: 'x',
scope: 'BOTH',
deleted: true,
parserType: 'DISMAX',
// The endpoint fills every list; an absent one reads as empty.
} as Parameters<typeof facetQueryParams>[0])
).toEqual([
['fq', 'deleted:true'],
['defType', 'dismax'],
Expand Down Expand Up @@ -142,7 +167,7 @@ describe('/query', () => {
'/reactome/facet',
'/reactome/search/grouped',
]);
expect(asked[1][1]).toEqual([
expect(request(1)[1]).toEqual([
['wt', 'json'],
['json.nl', 'flat'],
['fq', ''],
Expand Down Expand Up @@ -214,7 +239,10 @@ describe('/query', () => {
answer = (handler) =>
handler.endsWith('/facet') ? facets(0) : { response: { numFound: 0, docs: [] } };
const response = await get('query?query=zzz&types=Protein&scope=PHYSICAL_ENTITY');
expect([response.status, (await response.json()).targets]).toEqual([404, null]);
expect([response.status, ((await response.json()) as { targets: unknown }).targets]).toEqual([
404,
null,
]);
// As given, then without filters in the default scope, then deleted,
// then by dismax, then in every scope; then the target core.
expect(
Expand All @@ -234,8 +262,8 @@ describe('/query', () => {
['/target/search', '', ''],
]);
await until(() => reports.length === 1);
expect(reports[0].url.split('?')[0]).toBe('/report/search/notfound');
expect(reports[0].body).toBe('{"term":"zzz","resource":""}');
expect((report(0).url ?? '').split('?')[0]).toBe('/report/search/notfound');
expect(report(0).body).toBe('{"term":"zzz","resource":""}');
});

it('with facets but an empty page, names the targets in the 404 and reports them', async () => {
Expand All @@ -252,33 +280,33 @@ describe('/query', () => {
return grouped([]);
};
const response = await get('query?query=P15056&scope=BOTH&Start%20row=900');
expect([response.status, (await response.json()).targets]).toEqual([
expect([response.status, ((await response.json()) as { targets: unknown }).targets]).toEqual([
404,
[{ term: 'P15056', resource: 'UNIPROT', target: true }],
]);
await until(() => reports.length === 1);
expect(reports[0].url.split('?')[0]).toBe('/report/search/targets');
expect((report(0).url ?? '').split('?')[0]).toBe('/report/search/targets');
});

it('drops a filter the facets did not select from the search too, unless filters are forced', async () => {
for (const [forced, calls, type] of [
['', 2, ''],
['&Force%20filters=true', 1, 'type_facet:("Nope")'],
]) {
] as const) {
asked = [];
answer = (handler) =>
handler.endsWith('/facet') ? facets(8) : grouped([['Protein', [doc('1', 'TP53')]]]);
await get(`query?query=TP53&types=Nope${forced}`);
expect(asked.filter(([handler]) => handler.endsWith('/facet'))).toHaveLength(calls);
expect(fqs(asked.at(-1)[1])[1]).toBe(type);
expect(fqs(request(asked.length - 1)[1])[1]).toBe(type);
}
});

it("is Java's 500 for a blank scope with a query, and its 404 without one", async () => {
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([
expect(((await blank.json()) as { messages: string[] }).messages).toEqual([
'Something unexpected happened and the error has been reported.',
]);
answer = () => ({ response: { numFound: 0, docs: [] } });
Expand All @@ -299,6 +327,7 @@ describe('/query', () => {
const response = await get('query?query=TP53&species=Nowhere');
expect(response.status).toBe(404);
const search = asked.find(([handler]) => handler.endsWith('/search/grouped'));
if (!search) throw new Error('the grouped search was not asked');
expect([value(search[1], 'group.offset'), value(search[1], 'group.limit')]).toEqual(['0', '0']);
await until(() => reports.length === 1);
});
Expand Down Expand Up @@ -333,9 +362,9 @@ describe('/query', () => {
};
return { response: { numFound: 8, docs: [] } };
};
const body = await (
const body = (await (
await get('query?query=%20P04637&cluster=false&Start%20row=900&scope=BOTH')
).json();
).json()) as { targetResults: unknown };
// In the order Java's HashSet writes them, not merely both present.
expect(body.targetResults).toEqual([
{ term: 'P04637', resource: 'UNIPROT', target: true },
Expand All @@ -348,28 +377,28 @@ 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([
expect(((await response.json()) as { messages: string[] }).messages).toEqual([
'Something unexpected happened and the error has been reported.',
]);
});

it('leaves out the type of a group with none', async () => {
answer = (handler) =>
handler.endsWith('/facet') ? facets(8) : grouped([[null, [doc('1', 'TP53')]]]);
const body = await (await get('query?query=TP53')).json();
expect(Object.keys(body.results[0])).toEqual(['entries', 'entriesCount', 'rowCount']);
const body = (await (await get('query?query=TP53')).json()) as { results: object[] };
expect(Object.keys(body.results[0] ?? {})).toEqual(['entries', 'entriesCount', 'rowCount']);
});

it("is Spring's 400s, and Java's 500 for a blank cluster before Solr is asked", async () => {
asked = [];
const missing = await get('query');
expect(missing.status).toBe(400);
const parser = await get('query?query=x&parserType=lucene');
expect((await parser.json()).messages[0]).toMatch(
expect(((await parser.json()) as { messages: string[] }).messages[0]).toMatch(
/No enum constant org\.reactome\.server\.search\.domain\.ParserType\.lucene$/
);
const blank = await get('query?query=x&cluster=%20');
expect([blank.status, (await blank.json()).messages]).toEqual([
expect([blank.status, ((await blank.json()) as { messages: string[] }).messages]).toEqual([
500,
['Something unexpected happened and the error has been reported.'],
]);
Expand All @@ -384,7 +413,7 @@ describe('/query/paginated', () => {
handler.endsWith('/facet') ? facets(8) : grouped([['Protein', [doc('1', 'TP53')]]]);
await get('query/paginated?query=TP53&page=3&rowCount=10');
// Two types: five rows each, from the tenth.
expect([value(asked[1][1], 'group.offset'), value(asked[1][1], 'group.limit')]).toEqual([
expect([value(request(1)[1], 'group.offset'), value(request(1)[1], 'group.limit')]).toEqual([
'10',
'5',
]);
Expand All @@ -411,7 +440,7 @@ 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([
expect(((await response.json()) as { messages: string[] }).messages).toEqual([
'Something unexpected happened and the error has been reported.',
]);
// This search's own report, not a late one from the test before.
Expand All @@ -430,7 +459,7 @@ 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([
expect(((await response.json()) as { messages: string[] }).messages).toEqual([
'Something unexpected happened and the error has been reported.',
]);
});
Expand All @@ -443,13 +472,13 @@ describe('/query/paginated', () => {
'rowCount=10',
"Required request parameter 'page' for method parameter type Integer is not present",
],
]) {
] as const) {
const response = await get(`query/paginated?query=x&${query}`);
expect([query, response.status, (await response.json()).messages]).toEqual([
expect([
query,
400,
[message],
]);
response.status,
((await response.json()) as { messages: string[] }).messages,
]).toEqual([query, 400, [message]]);
}
});
});
Loading
Loading