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/013-server-types/tasks.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,9 @@ Each task: rename to `.ts`, update imports, file names that name it (R3 list), f

## Phase 6: Polish

- [ ] 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.
- [x] 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. query-search and species-search, 151 — #651.
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. search and service, the last two, with the library-update spec, 252 — #652. No `.mjs` is left in `tools/`, and `tools/load.spec.ts` fails if one is added, so a new module is TypeScript and checked. T023 is complete.

- [ ] 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,32 +4,37 @@
// it, what is read from its answer, and the checks Spring makes first.
// Java's answers measured on 16 Oct 2026.
import { URLSearchParams } from 'node:url';
import type { IncomingMessage, Server, ServerResponse } from 'node:http';
import type { AddressInfo } from 'node:net';
import { describe, expect, it, beforeAll, afterAll } from 'vitest';
import { app } from './server.ts';
import { collations, facetMapping, suggestionsFrom } from './search-endpoints.ts';
import { solr, queued } from './solr.ts';

let base;
let server;
let base: string;
let server: Server;

/**
* Until `ready()` holds, checking every 5ms for up to two seconds -- well
* inside the test's own five, so a condition never met fails here, saying so.
*/
async function until(ready) {
async function until(ready: () => boolean) {
for (let tries = 0; !ready() && tries < 400; tries += 1)
await new Promise((resolve) => setTimeout(resolve, 5));
expect(ready()).toBe(true);
}

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())));

/** An extended collation, as SolrJ reads one: its query, and more. */
const extended = { collationQuery: 'tp53', hits: 3 };

describe('collations, as SolrJ reads them', () => {
it('reads the flat collation list, strings or extended objects', () => {
Expand All @@ -40,15 +45,16 @@ describe('collations, as SolrJ reads them', () => {
).toEqual(['apoptosis', 'apoptotic']);
expect(
collations({
spellcheck: { collations: ['collation', { collationQuery: 'tp53', hits: 3 }] },
// An extended collation carries more than the query, which is all that is read.
spellcheck: { collations: ['collation', extended] },
})
).toEqual(['tp53']);
expect(collations({})).toEqual([]);
});
});

describe('facetMapping, as SolrConverter.getFacetMap builds it', () => {
const answer = (fields, numFound = 7) => ({
const answer = (fields: Record<string, (string | number | null)[]>, numFound = 7) => ({
response: { numFound },
facet_counts: { facet_queries: {}, facet_fields: fields },
});
Expand Down Expand Up @@ -93,15 +99,16 @@ describe('facetMapping, as SolrConverter.getFacetMap builds it', () => {

describe('suggestionsFrom, the collations that find something', () => {
it('asks the handler with spellcheck.q, then /exists for each, keeping order', async () => {
const asked = [];
const ask = async (handler, params, options) => {
const asked: unknown[][] = [];
const ask: Parameters<typeof suggestionsFrom>[2] = async (handler, params, options) => {
asked.push([handler, params, options]);
if (handler === '/suggest')
return {
spellcheck: { collations: ['collation', 'a', 'collation', 'b', 'collation', 'c'] },
};
const q = params.find(([name]) => name === 'q')[1];
return { response: { numFound: q === 'b' ? 0 : 2 } };
const q = params.find(([name]) => name === 'q');
if (!q) throw new Error('no q was sent');
return { response: { numFound: q[1] === 'b' ? 0 : 2 } };
};
expect(await suggestionsFrom('/suggest', 'x', ask)).toEqual(['a', 'c']);
expect(asked[0]).toEqual(['/suggest', [['spellcheck.q', 'x']], { signal: undefined }]);
Expand All @@ -114,7 +121,7 @@ describe('the suggest and spellcheck endpoints, before Solr', () => {
it(`/${route} without a query is Spring's 400`, async () => {
const response = await fetch(`${base}/ContentService/search/${route}`);
expect(response.status).toBe(400);
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 All @@ -129,35 +136,42 @@ describe('the suggest and spellcheck endpoints, before Solr', () => {
});

describe('the suggest endpoint, against a stand-in Solr', () => {
let solrServer;
let asked;
let answer;
const saved = {};
/** How the stand-in Solr answers: the request, its form, and the response to write. */
type Answer = (
request: IncomingMessage,
params: URLSearchParams,
response: ServerResponse
) => void;
let solrServer: Server;
let asked: unknown[][];
let answer: Answer;
const saved: Record<string, string | undefined> = {};
const names = ['SOLR_URL', 'SOLR_USER', 'SOLR_PASSWORD'];

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', () => answer(request, new URLSearchParams(body), response));
});
await new Promise((resolve) => solrServer.listen(0, '127.0.0.1', resolve));
await new Promise<void>((resolve) => solrServer.listen(0, '127.0.0.1', resolve));
for (const name of names) saved[name] = process.env[name];
process.env.SOLR_USER = 'reader';
process.env.SOLR_PASSWORD = 'not-a-secret';
process.env['SOLR_USER'] = 'reader';
process.env['SOLR_PASSWORD'] = 'not-a-secret';
});

afterAll(async () => {
for (const name of names)
if (saved[name] === undefined) delete process.env[name];
else process.env[name] = saved[name];
await new Promise((resolve) => solrServer.close(resolve));
await new Promise<void>((resolve) => solrServer.close(() => resolve()));
});

const useSolr = () =>
(process.env.SOLR_URL = `http://127.0.0.1:${solrServer.address().port}/solr/reactome`);
const collationsOf = (values) => ({
(process.env['SOLR_URL'] =
`http://127.0.0.1:${(solrServer.address() as AddressInfo).port}/solr/reactome`);
const collationsOf = (values: string[]) => ({
spellcheck: { collations: values.flatMap((value) => ['collation', value]) },
});

Expand All @@ -169,7 +183,9 @@ describe('the suggest endpoint, against a stand-in Solr', () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify(
request.url.endsWith('/suggest') ? collationsOf(['a,b']) : { response: { numFound: 1 } }
(request.url ?? '').endsWith('/suggest')
? collationsOf(['a,b'])
: { response: { numFound: 1 } }
)
);
};
Expand All @@ -184,20 +200,20 @@ describe('the suggest endpoint, against a stand-in Solr', () => {

it('authenticates when a user is set, and not otherwise, as Java does', async () => {
useSolr();
const sent = [];
const sent: (string | undefined)[] = [];
answer = (request, _params, response) => {
sent.push(request.headers.authorization);
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(collationsOf([])));
};
await fetch(`${base}/ContentService/search/suggest?query=x`);
process.env.SOLR_USER = '';
process.env.SOLR_PASSWORD = '';
process.env['SOLR_USER'] = '';
process.env['SOLR_PASSWORD'] = '';
try {
await fetch(`${base}/ContentService/search/suggest?query=x`);
} finally {
process.env.SOLR_USER = 'reader';
process.env.SOLR_PASSWORD = 'not-a-secret';
process.env['SOLR_USER'] = 'reader';
process.env['SOLR_PASSWORD'] = 'not-a-secret';
}
expect(sent).toEqual([
`Basic ${Buffer.from('reader:not-a-secret').toString('base64')}`,
Expand All @@ -212,14 +228,14 @@ describe('the suggest endpoint, against a stand-in Solr', () => {
reached = true;
response.end('{}');
};
process.env.SOLR_PASSWORD = '';
process.env['SOLR_PASSWORD'] = '';
try {
const response = await fetch(`${base}/ContentService/search/suggest?query=x`);
expect(response.status).toBe(500);
const health = await (await fetch(`${base}/health`)).json();
const health = (await (await fetch(`${base}/health`)).json()) as { solr: boolean };
expect(health.solr).toBe(false);
} finally {
process.env.SOLR_PASSWORD = 'not-a-secret';
process.env['SOLR_PASSWORD'] = 'not-a-secret';
}
expect(reached).toBe(false);
});
Expand All @@ -232,16 +248,17 @@ describe('the suggest endpoint, against a stand-in Solr', () => {
};
const response = await fetch(`${base}/ContentService/search/spellcheck?query=x`);
expect(response.status).toBe(500);
const body = await response.json();
const body = (await response.json()) as { code: number; reason: string };
expect(body.code).toBe(500);
expect(body.reason).toBe('INTERNAL_SERVER_ERROR');
});

it('never repeats credentials written into SOLR_URL', async () => {
process.env.SOLR_URL = `http://someone:hunter2@127.0.0.1:${solrServer.address().port}/solr/reactome`;
const logged = [];
process.env['SOLR_URL'] =
`http://someone:hunter2@127.0.0.1:${(solrServer.address() as AddressInfo).port}/solr/reactome`;
const logged: string[] = [];
const error = console.error;
console.error = (...line) => logged.push(line.join(' '));
console.error = (...line: unknown[]) => logged.push(line.join(' '));
try {
const response = await fetch(`${base}/ContentService/search/suggest?query=x`);
expect(response.status).toBe(500);
Expand All @@ -265,7 +282,7 @@ describe('the suggest endpoint, against a stand-in Solr', () => {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify(
request.url.endsWith('/suggest')
(request.url ?? '').endsWith('/suggest')
? collationsOf(['a', 'b'])
: { response: { numFound: 1 } }
)
Expand All @@ -281,15 +298,15 @@ describe('the suggest endpoint, against a stand-in Solr', () => {

it('refuses past 64 waiting with a 503 to retry, and serves the rest', async () => {
useSolr();
const held = [];
const held: ServerResponse[] = [];
answer = (_request, _params, response) => held.push(response);
const settled = [];
const settled: unknown[][] = [];
const pending = Array.from({ length: 70 }, () =>
fetch(`${base}/ContentService/search/suggest?query=x`).then(async (response) => {
const answered = [
response.status,
response.headers.get('retry-after'),
(await response.json()).reason,
((await response.json()) as { reason: string }).reason,
];
settled.push(answered);
return answered;
Expand All @@ -299,7 +316,7 @@ describe('the suggest endpoint, against a stand-in Solr', () => {
await until(() => settled.length === 4 && held.length === 2);
expect(settled).toEqual(Array(4).fill([503, '5', 'SERVICE_UNAVAILABLE']));

const release = (response) => {
const release = (response: ServerResponse) => {
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(collationsOf([])));
};
Expand All @@ -311,7 +328,7 @@ describe('the suggest endpoint, against a stand-in Solr', () => {

it('a waiter whose caller gives up leaves the queue and never reaches Solr', async () => {
useSolr();
const held = [];
const held: ServerResponse[] = [];
let arrived = 0;
answer = (_request, _params, response) => {
arrived += 1;
Expand All @@ -335,7 +352,7 @@ describe('the suggest endpoint, against a stand-in Solr', () => {

it('a client that disconnects takes its queued call with it', async () => {
useSolr();
const held = [];
const held: ServerResponse[] = [];
let arrived = 0;
answer = (_request, _params, response) => {
arrived += 1;
Expand Down Expand Up @@ -371,10 +388,10 @@ describe('the suggest endpoint, against a stand-in Solr', () => {
],
],
['icon/facet', '/icon/facet', [['q', '{!term f=type}icon']]],
])
] as const)
it(`/${route} asks ${handler} as search-core does, and writes its FacetMapping`, async () => {
useSolr();
const asked = [];
const asked: unknown[][] = [];
answer = (request, sent, response) => {
asked.push([request.url, [...sent]]);
response.setHeader('Content-Type', 'application/json');
Expand Down Expand Up @@ -419,6 +436,6 @@ describe('the suggest endpoint, against a stand-in Solr', () => {
};
const response = await fetch(`${base}/ContentService/search/icon/facet`);
expect(response.status).toBe(500);
expect((await response.json()).reason).toBe('INTERNAL_SERVER_ERROR');
expect(((await response.json()) as { reason: string }).reason).toBe('INTERNAL_SERVER_ERROR');
});
});
Loading
Loading