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.
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.

- [ ] 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,13 +5,19 @@
// with a stand-in graph. PhysicalEntityController over graph-core's
// PhysicalEntityRepository, whose queries are kept as written. The entities
// here are invented; Java's messages 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';

const asked = [];
let rows = [];
let failure;
/** A query's parameters, as the service passes them. */
type Params = { id?: unknown; taxId?: unknown; [name: string]: unknown };
/** Each query the stand-in graph was asked, with its parameters. */
const asked: [string, Params][] = [];
let rows: unknown[] = [];
/** What the stand-in graph throws next, if anything. */
let failure: Error | undefined;
vi.mock('./graph.ts', () => ({
read: async (cypher, params) => {
read: async (cypher: string, params: Params) => {
asked.push([cypher, params]);
if (failure) throw failure;
return rows;
Expand All @@ -23,34 +29,43 @@ 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/${path}`);
const last = () => asked[asked.length - 1];
const oneLine = (cypher) => cypher.replace(/\s+/g, ' ').trim();
const entity = (dbId, schemaClass) => ({
const get = (path: string) => fetch(`${base}/ContentService/data/${path}`);
/** 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 oneLine = (cypher: string) => cypher.replace(/\s+/g, ' ').trim();
const entity = (dbId: number, schemaClass: string) => ({
dbId,
stId: `R-HSA-${dbId}`,
displayName: `E${dbId}`,
schemaClass,
});
const messages = async (response) => [response.status, (await response.json()).messages];
/** 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/complex/{id}/subunits', () => {
it("runs graph-core's query as written and writes the entities in the rows' order", async () => {
rows = [{ pe: entity(2, 'Complex') }, { pe: entity(1, 'EntityWithAccessionedSequence') }];
failure = undefined;
asked.length = 0;
const response = await get('complex/R-HSA-5/subunits');
expect((await response.json()).map((e) => e.dbId)).toEqual([2, 1]);
expect(((await response.json()) as { dbId: number }[]).map((e) => e.dbId)).toEqual([2, 1]);
expect(oneLine(last()[0])).toBe(
'MATCH (:PhysicalEntity{stId: $id})-[:hasComponent|hasMember|hasCandidate|repeatedUnit|proteinMarker|RNAMarker*]->(pe:PhysicalEntity) RETURN DISTINCT pe'
);
Expand Down Expand Up @@ -111,7 +126,7 @@ describe('/data/complexes/{resource}/{identifier}', () => {
failure = undefined;
asked.length = 0;
const response = await get('complexes/(%3Fi)uniprot/P00533');
expect((await response.json()).map((e) => e.dbId)).toEqual([3]);
expect(((await response.json()) as { dbId: number }[]).map((e) => e.dbId)).toEqual([3]);
expect(oneLine(last()[0])).toBe(
'MATCH (rd:ReferenceDatabase)<-[:referenceDatabase]-(n{identifier:$identifier}) WHERE rd.displayName =~ $resource WITH DISTINCT n MATCH (n)<-[:referenceEntity|referenceSequence|crossReference|referenceGene*]-(pe:PhysicalEntity) WITH DISTINCT pe MATCH (c:Complex)-[:hasComponent|hasMember|hasCandidate|repeatedUnit|proteinMarker|RNAMarker*]->(pe) RETURN DISTINCT c'
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,18 @@
// InteractionManager over graph-core's InteractorCountRepository and
// PathwayRepository, their queries kept as written. The counts 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 = { accs?: string[]; speciesName?: string | null; [name: string]: unknown };
/** The stand-in graph's rows for a query. */
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);
},
Expand All @@ -23,26 +29,35 @@ 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/interactors/static/${path}`);
const post = (body, type = 'text/plain') =>
const get = (path: string) => fetch(`${base}/ContentService/interactors/static/${path}`);
const post = (body: string | undefined, type = 'text/plain') =>
fetch(`${base}/ContentService/interactors/static/molecules/summary`, {
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];
const oneLine = (cypher) => cypher.replace(/\s+/g, ' ').trim();
/** Status and the messages of Java's error envelope. */
const messages = async (response: Response) => [
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;
};
const oneLine = (cypher: string) => cypher.replace(/\s+/g, ' ').trim();

const COUNT =
'MATCH (t:ReferenceEntity)<-[:interactor]-(in:Interaction) WHERE t.variantIdentifier IN $accs OR (t.variantIdentifier IS NULL AND t.identifier IN $accs) RETURN DISTINCT t.identifier AS s, COUNT(DISTINCT in) as t';
Expand Down Expand Up @@ -77,10 +92,10 @@ describe('POST /interactors/static/molecules/summary', () => {
expect(await response.text()).toBe(
'{"resource":"static","entities":[{"acc":"Q00987","count":78},{"acc":"P04637","count":249}]}'
);
expect([...last()[1].accs].sort()).toEqual(['P04637', 'Q00987', 'xyz']);
expect([...(last()[1].accs ?? [])].sort()).toEqual(['P04637', 'Q00987', 'xyz']);
// Newline and tab separate too.
await post('P1\nP2\tP3');
expect([...last()[1].accs].sort()).toEqual(['P1', 'P2', 'P3']);
expect([...(last()[1].accs ?? [])].sort()).toEqual(['P1', 'P2', 'P3']);
});

it("answers Java's errors: 415, 422, and a 404 naming the body as sent", async () => {
Expand All @@ -107,7 +122,7 @@ describe('/interactors/static/molecule/{acc}/pathways', () => {
answer = () => [{ p: { dbId: 3, stId: 'R-HSA-3', schemaClass: 'Pathway' } }];
asked.length = 0;
const response = await get('molecule/P1/pathways?species=Homo%20sapiens');
expect((await response.json()).map((p) => p.dbId)).toEqual([3]);
expect(((await response.json()) as { dbId: number }[]).map((p) => p.dbId)).toEqual([3]);
expect(oneLine(last()[0])).toBe(
'MATCH (a:ReferenceEntity)<-[:interactor]-()-[:interactor]->(b:ReferenceEntity) WHERE a.identifier = $acc OR a.variantIdentifier = $acc MATCH path=(p:Pathway)-[:hasEvent|input|output|catalystActivity|physicalEntity|regulatedBy|regulator*]->(pe:PhysicalEntity)-[:referenceEntity]->(b) WHERE p.speciesName = $speciesName AND SINGLE(e IN NODES(path) WHERE (e:Pathway)) RETURN DISTINCT p'
);
Expand All @@ -131,7 +146,7 @@ describe('/interactors/static/molecule/{acc}/pathways', () => {
});

it('is a 404 with no species, which matches nothing', async () => {
answer = (cypher, params) => (params.speciesName === null ? [] : [{ p: { dbId: 3 } }]);
answer = (_cypher, params) => (params.speciesName === null ? [] : [{ p: { dbId: 3 } }]);
expect(await messages(await get('molecule/P1/pathways'))).toEqual([
404,
['No pathways found for molecule: P1'],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// the POST's content type, and the 405 Java answers for a method a mapping
// does not take. Every expected value below was measured against Java's
// /data/orthology and /data/orthologies on 15 Oct 2026.
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';
import { JavaHashMap } from './java-hashmap.ts';
Expand All @@ -13,18 +15,22 @@ import { orthologyIds } from './body.ts';
import { springPathLong } from './spring.ts';
import { plainText, plainTextProblem } from './body.ts';
import { JsonMap, mapNodes, VIEW, writeDomain } from './domain.ts';
import type { GraphNode } from './graph.ts';

let base;
let server;
/** A found object, as far as orthologyMap reads one: whether it is there. */
const node = (id: number | string): GraphNode => ({ id, labels: [], props: {} });

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

describe('springPathLong, a Long path variable as Spring converts it', () => {
it('reads what Long.valueOf and Long.decode read', () => {
Expand All @@ -50,7 +56,7 @@ describe('springPathLong, a Long path variable as Spring converts it', () => {
});

it("refuses the rest with Java's 400, naming what it could not parse", () => {
const refused = (text) =>
const refused = (text: string) =>
`Failed to convert value of type 'java.lang.String' to required type 'java.lang.Long'; ` +
`nested exception is java.lang.NumberFormatException: For input string: "${text}"`;
for (const [given, named] of [
Expand All @@ -60,7 +66,7 @@ describe('springPathLong, a Long path variable as Spring converts it', () => {
['0x', ''],
['1.5', '1.5'],
['48892.0', '48892.0'],
])
] as const)
expect(springPathLong(given, 'speciesId'), given).toEqual({
status: 400,
error: refused(named),
Expand All @@ -77,8 +83,8 @@ describe('springPathLong, a Long path variable as Spring converts it', () => {
});

describe('JavaHashMap, in the order Java iterates a HashMap', () => {
const order = (keys) => {
const map = new JavaHashMap();
const order = (keys: string[]) => {
const map = new JavaHashMap<string, string>();
for (const key of keys) map.set(key, key);
return [...map.keys()];
};
Expand Down Expand Up @@ -111,9 +117,9 @@ describe('JavaHashMap, in the order Java iterates a HashMap', () => {
// 16 buckets hold 12; the 13th key doubles the table, and keys that share
// a bucket at 16 keep their relative order in each half at 32.
const keys = Array.from({ length: 13 }, (_, at) => `k${at * 16}`);
const map = new JavaHashMap();
const map = new JavaHashMap<string, boolean>();
for (const key of keys) map.set(key, true);
const hash = (key) => {
const hash = (key: string) => {
let h = 0;
for (const unit of key) h = (Math.imul(31, h) + unit.charCodeAt(0)) | 0;
return (h ^ (h >>> 16)) >>> 0;
Expand Down Expand Up @@ -160,13 +166,15 @@ describe('orthologyMap, the two maps getOrthologies builds', () => {
'R-HSA-69620.9',
...Array.from({ length: 11 }, (_, at) => `x${at + 1}`),
];
const found = new Map(ids.map((id) => [id, id.startsWith('R') ? { id } : null]));
const found = new Map<string, GraphNode | null>(
ids.map((id) => [id, id.startsWith('R') ? node(id) : null])
);
expect([...orthologyMap(ids, found).keys()]).toEqual(['R-HSA-69620.11', 'R-HSA-69620.9']);
});

it('drops an id with nothing found', () => {
const found = new Map([
['R-HSA-69620', { id: 1 }],
const found = new Map<string, GraphNode | undefined>([
['R-HSA-69620', node(1)],
['R-ALL-29358', undefined],
]);
expect([...orthologyMap(['R-HSA-69620', 'R-ALL-29358'], found).keys()]).toEqual([
Expand All @@ -177,7 +185,7 @@ describe('orthologyMap, the two maps getOrthologies builds', () => {

describe('plainText, the POST body in its charset as the JDK decodes it', () => {
// Each expected string is the JDK's new String(bytes, charset).
const read = (charset, hex) =>
const read = (charset: string, hex: string) =>
plainText(Buffer.from(hex, 'hex'), `text/plain;charset=${charset}`);

it('reads UTF-16 big-endian without a mark, and by its mark with one', () => {
Expand Down Expand Up @@ -278,9 +286,11 @@ describe('writeDomain with a map', () => {
});

describe('the orthology endpoints, before any query', () => {
const get = (path, init) => fetch(`${base}/ContentService/data${path}`, init);
const envelope = async (response) => {
const body = await response.json();
const get = (path: string, init?: RequestInit) =>
fetch(`${base}/ContentService/data${path}`, init);
/** Status, reason and messages: Java's error envelope. */
const envelope = async (response: Response): Promise<[number, string, string[]]> => {
const body = (await response.json()) as { reason: string; messages: string[] };
return [response.status, body.reason, body.messages];
};

Expand Down Expand Up @@ -311,11 +321,11 @@ describe('the orthology endpoints, before any query', () => {
]);
});

const post = (species, body, type = 'text/plain') =>
const post = (species: string, body: string | undefined, type = 'text/plain') =>
get(`/orthologies/ids/species/${species}`, {
method: 'POST',
headers: type ? { 'Content-Type': type } : {},
body,
...(body === undefined ? {} : { body }),
});

it('checks the content type, then the species, then the body', async () => {
Expand Down Expand Up @@ -363,14 +373,18 @@ describe('a method a mapping does not take', () => {
fetch(`${base}/ContentService/data/orthology/R-HSA-69620/species/48892`, { method: 'PUT' }),
fetch(`${base}/ContentService/data/orthologies/ids/species/48892`),
]);
const bodies = await Promise.all(answers.map((response) => response.json()));
const bodies = await Promise.all(
answers.map(
async (response) => (await response.json()) as { reason: string; messages: string[] }
)
);
expect(answers.map((response) => response.status)).toEqual([405, 405, 405]);
expect(bodies.map((body) => body.messages[0])).toEqual([
"Request method 'POST' not supported",
"Request method 'PUT' not supported",
"Request method 'GET' not supported",
]);
expect(bodies[0].reason).toBe('METHOD_NOT_ALLOWED');
expect(bodies[0]?.reason).toBe('METHOD_NOT_ALLOWED');
});
});

Expand All @@ -379,7 +393,7 @@ describe("Spring's guard against a download named by the URL", () => {
// written for a path whose last segment ends in an extension Spring does not trust is sent as an attachment-proof
// inline f.txt. Each case measured against Java on 15 Oct 2026, on the
// species segment, which fails before any query.
const disposition = async (last) => {
const disposition = async (last: string) => {
const response = await fetch(`${base}/ContentService/data/orthology/xyz/species/${last}`);
await response.arrayBuffer();
return response.headers.get('content-disposition');
Expand All @@ -395,7 +409,7 @@ describe("Spring's guard against a download named by the URL", () => {
'48892.wasm',
'48892.jsonp',
'48892.5',
])
] as const)
it(`marks ${last}`, async () => {
expect(await disposition(last)).toBe(guarded);
});
Expand All @@ -408,7 +422,7 @@ describe("Spring's guard against a download named by the URL", () => {
'48892.SVG',
'48892.',
'48892.json',
])
] as const)
it(`leaves ${last} alone`, async () => {
expect(await disposition(last)).toBe(null);
});
Expand Down
Loading
Loading