Skip to content
92 changes: 83 additions & 9 deletions tools/content-node/citation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,18 +285,24 @@ describe('StaticCitation', () => {

vi.mock('./graph.ts', async () => ({
read: async (cypher, params) => {
if (cypher.includes('DBInfo')) return [{ version: 97 }];
globalThis.graphAsked = params;
return globalThis.graphRows ?? [];
// The release as the service reads it: releaseNumber, not another property.
if (cypher === 'MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1')
return [{ version: 97 }];
const shared = globalThis as { graphAsked?: unknown; graphRows?: unknown[] };
shared.graphAsked = params;
return shared.graphRows ?? [];
},
configured: () => true,
close: async () => {},
envSetting: (key, fallback = '') => process.env[key] || fallback,
}));

/** What the stand-in graph answers, and what it was last asked. */
const graph = globalThis as { graphAsked?: unknown; graphRows?: unknown[] };

describe('the citation endpoints', () => {
let base;
let server;
let base: string;
let server: Server;
let pmc: Server;
let pmcAsked: (string | undefined)[] = [];

Expand Down Expand Up @@ -343,11 +349,11 @@ describe('the citation endpoints', () => {
});

it('/pathway/{id} is {} for an id that is no event, and export an empty 200', async () => {
globalThis.graphRows = [];
graph.graphRows = [];
const pathway = await fetch(`${base}/ContentService/citation/pathway/R-HSA-9?dateAccessed=d`);
expect([pathway.status, await pathway.text()]).toEqual([200, '{}']);
// The id from the path, not the path's own words.
expect(globalThis.graphAsked).toEqual({ id: 'R-HSA-9' });
expect(graph.graphAsked).toEqual({ id: 'R-HSA-9' });
const exported = await fetch(
`${base}/ContentService/citation/export?isPathway=true&id=xyz&ext=bib&dateAccessed=d`
);
Expand All @@ -358,6 +364,74 @@ describe('the citation endpoints', () => {
]);
});

// An event as graph-core's enhanced query loads it, with nothing related.
const eventRow = (schemaClass: string, labels: string[]) => ({
root: {
id: 1,
labels: ['DatabaseObject', 'Event', ...labels],
props: {
dbId: 9,
stId: 'R-HSA-9',
displayName: 'An event',
releaseDate: '2020-06-15',
doi: '10.3180/R-HSA-9.1',
schemaClass,
},
},
order: [],
nodes: [],
relationships: [],
});

it("/pathway/{id} cites the event by the id as given; only a pathway's by its DOI", async () => {
try {
graph.graphRows = [eventRow('Pathway', ['Pathway'])];
const pathway = await (
await fetch(`${base}/ContentService/citation/pathway/9?dateAccessed=d`)
).json();
// Asked by a versioned stable id, neither its stId nor its dbId: the
// link names the id as given.
graph.graphRows = [eventRow('Reaction', ['ReactionLikeEvent', 'Reaction'])];
const reaction = await (
await fetch(`${base}/ContentService/citation/pathway/R-HSA-9.1?dateAccessed=d`)
).json();
expect([pathway, reaction]).toEqual([
{
imageCitation:
'Image Citation for An event. Reactome, 97, https://doi.org/10.3180/R-HSA-9.1 (d)',
pathwayCitation:
'The Reactome Consortium (2020). An event. Reactome, 97, https://doi.org/10.3180/R-HSA-9.1 (d)',
},
{
imageCitation:
'Image Citation for An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9.1 (d)',
pathwayCitation:
'The Reactome Consortium (2020). An event. Reactome, 97, https://reactome.org/content/detail/R-HSA-9.1 (d)',
},
]);
} finally {
graph.graphRows = [];
}
});

it('export writes each format as its own type and extension', async () => {
for (const [ext, type, extension] of [
['bib', 'application/x-bibtex; charset=utf-8', 'bib'],
['ris', 'application/x-research-info-systems; charset=utf-8', 'ris'],
['txt', 'text/plain; charset=utf-8', 'txt'],
]) {
const response = await fetch(
`${base}/ContentService/citation/export?isPathway=false&id=12345&ext=${ext}&dateAccessed=d`
);
expect([
ext,
response.status,
response.headers.get('content-type'),
response.headers.get('content-disposition'),
]).toEqual([ext, 200, type, `attachment; filename="reactome_citation_12345.${extension}"`]);
}
});

it('/static/{id} is "unable to fetch" for an answer Java cannot read', async () => {
globalThis.pmcAnswer = { resultList: { result: [{ ...europePmc, journalInfo: [] }] } };
try {
Expand Down Expand Up @@ -474,7 +548,7 @@ describe('the citation endpoints', () => {

it("/pathway/{id} is Java's 500 for an event it cannot cite", async () => {
// A reaction with no release date: Java's getReleaseDate().substring NPEs.
globalThis.graphRows = [
graph.graphRows = [
{
root: {
id: 1,
Expand All @@ -494,7 +568,7 @@ describe('the citation endpoints', () => {
['Something unexpected happened and the error has been reported.'],
]);
} finally {
globalThis.graphRows = [];
graph.graphRows = [];
}
});

Expand Down
36 changes: 22 additions & 14 deletions tools/content-node/contained-events.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,14 @@ vi.mock('./graph.ts', () => ({
asked.push([cypher, params]);
if (cypher.includes('[r:hasEvent*1..100]'))
return params.stId === 'R-HSA-100' || params.dbId ? (records ?? RECORDS) : [];
for (const [type, rows] of Object.entries(lazy))
if (cypher.includes(`[r:${type}]`)) return rows;
// A lazy load by what it asks: `${type} ${direction} ${dbId}`, so one in
// the wrong direction, or of another event, finds nothing.
const load = /\{dbId:\$dbId\}\)(<-|-)\[r:(\w+)\](->|-)/.exec(cypher);
if (load)
return (
lazy[`${load[2]} ${load[1] === '<-' ? 'INCOMING' : 'OUTGOING'} ${Number(params.dbId)}`] ??
[]
);
return [];
},
configured: () => true,
Expand Down Expand Up @@ -113,22 +119,24 @@ describe('/data/pathway/{id}/containedEvents/{attributeName}', () => {
expect(await response.text()).toBe('[R-HSA-200, R-HSA-300]');
});

it('loads what findAll did not bring, outgoing relationships too', async () => {
lazy = {
compartment: [
{
m: {
id: 9,
labels: ['DatabaseObject', 'Compartment'],
props: { dbId: 70101, displayName: 'cytosol', schemaClass: 'Compartment' },
},
n: null,
it("loads what findAll did not bring, outgoing relationships too, each event's own", async () => {
const compartment = (id, dbId, displayName) => [
{
m: {
id,
labels: ['DatabaseObject', 'Compartment'],
props: { dbId, displayName, schemaClass: 'Compartment' },
},
],
n: null,
},
];
lazy = {
'compartment OUTGOING 200': compartment(9, 70101, 'cytosol'),
'compartment OUTGOING 300': compartment(10, 7660, 'nucleoplasm'),
};
const response = await get('R-HSA-100/containedEvents/compartment');
expect(await response.text()).toBe(
'[70101\tcytosol\tCompartment\n, 70101\tcytosol\tCompartment\n]'
'[70101\tcytosol\tCompartment\n, 7660\tnucleoplasm\tCompartment\n]'
);
lazy = {};
});
Expand Down
28 changes: 24 additions & 4 deletions tools/content-node/diagram-search.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ const objects = {
};

vi.mock('./graph.ts', () => ({
read: async (_cypher, params) => (objects[params.id] ? [objects[params.id]] : []),
// 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) =>
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,
Expand Down Expand Up @@ -254,9 +260,23 @@ describe('/diagram/{diagram}/occurrences/{instance}', () => {
});
const none = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433');
expect(await none.text()).toBe('{"diagramEntity":"R-HSA-68886","inDiagram":false}');
answer = () => ({ response: { numFound: 1, docs: [{ occurrences: ['R-HSA-68886:true:x'] }] } });
const short = await get('diagram/R-HSA-68886/occurrences/R-HSA-141433');
expect(short.status).toBe(500);
// Java's ArrayIndexOutOfBoundsException, with its own message
// (GlobalExceptionHandler.handleUnclassified): extractRawOccurrence reads
// [0], [2], [3], then [1], so three parts fail at 3, two at 2 -- and
// one at 2, not 1: the order is Java's, not the parts'.
for (const [occurrence, message] of [
['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'],
]) {
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([
occurrence,
500,
[message],
]);
}
});

it('asks again in every scope, then is the 404', async () => {
Expand Down
11 changes: 10 additions & 1 deletion tools/content-node/discover.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,9 @@ const graph =
if (/AS related/.test(cypher))
return [{ labels: (load[0] ?? LOADED).root.labels, related: load.length > 0 }];
if (/MATCH \(n\)-\[r\]-\(m\)/.test(cypher)) return load;
if (/DBInfo/.test(cypher)) return [{ version: 97 }];
// The release as the service reads it: releaseNumber, not another property.
if (cypher === 'MATCH (n:DBInfo) RETURN n.releaseNumber AS version LIMIT 1')
return [{ version: 97 }];
// The edit's author, with no stoichiometry on the relationship: once.
if (/<-\[r:author\]-/.test(cypher) && Number(params.dbId) === 8)
return [{ m: { dbId: 20, surname: 'Roe' }, n: null }];
Expand Down Expand Up @@ -285,5 +287,12 @@ describe("/data/discover/{identifier}: Java's errors", () => {
const load = asked.find((cypher) => /MATCH \(n\)-\[r\]-\(m\)/.test(cypher));
expect(load).toBeDefined();
expect(load).not.toMatch(/ORDER BY|OPTIONAL/);
// The object by the id asked, and the three columns mapResult reads.
expect(load?.replace(/\s+/g, ' ')).toBe(
'MATCH (n:DatabaseObject) WHERE n.stId = $id WITH n LIMIT 1 MATCH (n)-[r]-(m) ' +
'RETURN {id: id(n), labels: labels(n), props: properties(n)} AS root, ' +
'COLLECT({id: id(r), type: type(r), start: id(startNode(r)), end: id(endNode(r)), props: properties(r)}) AS relationships, ' +
'COLLECT({id: id(m), labels: labels(m), props: properties(m)}) AS nodes'
);
});
});
42 changes: 39 additions & 3 deletions tools/content-node/flag.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,15 @@ const species = {

vi.mock('./graph.ts', () => ({
read: async (cypher, params) => {
if (cypher.includes(':Species')) return [species[params.name] ?? []].flat();
return objects[params.id] ? [objects[params.id]] : [];
// 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'))
return cypher.includes('MATCH (n:Species) WHERE $name IN n.name RETURN')
? [species[params.name] ?? []].flat()
: [];
return cypher.includes('n.hasDiagram AS hasDiagram') && objects[params.id]
? [objects[params.id]]
: [];
},
configured: () => true,
close: async () => {},
Expand Down Expand Up @@ -127,6 +134,18 @@ describe('/diagram/{pathwayId}/flag', () => {
]);
});

it("is Java's index failure for an occurrence of fewer than four parts", async () => {
// extractRawOccurrence reads [0], [2], [3], then [1]: two parts fail at 2.
answer = () => ({
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'],
]);
});

it('flags through interactors when asked, and is the 404 for nothing', async () => {
asked = [];
answer = () => ({ response: { numFound: 0, docs: [] } });
Expand All @@ -147,6 +166,9 @@ describe('/diagram/{pathwayId}/flag', () => {
expect([bad.status, (await bad.json()).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([
'Something unexpected happened and the error has been reported.',
]);
expect(asked).toHaveLength(0);
});
});
Expand Down Expand Up @@ -214,10 +236,24 @@ describe('/fireworks/flag', () => {
]);
});

it("is Java's failure, not a guess, when two Species share the name", async () => {
it('is a failure, before Solr is asked, when two Species share the name', async () => {
asked = [];
const response = await get('fireworks/flag?query=x&species=Twice');
// Status only: Java's message here is Spring Data's
// IncorrectResultSizeDataAccessException, which has not been measured.
expect(response.status).toBe(500);
expect(asked).toHaveLength(0);
});

it("is Java's index failure for an occurrence of fewer than four parts", async () => {
// fireworksFlagging reads [0], then [3]: one part fails at 3, not 1.
answer = () => ({
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'],
]);
});
});
5 changes: 4 additions & 1 deletion tools/content-node/icon.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,10 @@ describe('searchEntry, as SolrConverter.buildEntry builds an Entry', () => {
});

it('fails, as Java does, on an entity with fewer than four parts', () => {
expect(() => searchEntry({ ...icon, iconPhysicalEntities: ['a#b#c#'] }, {})).toThrow();
// Its ArrayIndexOutOfBoundsException: read [0] to [3], three parts fail at 3.
expect(() => searchEntry({ ...icon, iconPhysicalEntities: ['a#b#c#'] }, {})).toThrow(
expect.objectContaining({ javaMessage: 'Index 3 out of bounds for length 3' })
);
});

it('id is the stId when there is one, else the dbId', () => {
Expand Down
Loading
Loading