From 8eaee905a733d8e2326c2dac69e24a13454baab4 Mon Sep 17 00:00:00 2001 From: Adam Wright Date: Mon, 14 Sep 2026 15:04:13 +0000 Subject: [PATCH] Fix nine formatters that read field paths the API never returns Every tool in this commit called its endpoint correctly and reported success. What they got wrong was the *shape* of the reply, so they rendered "undefined" -- or, worse, silently rendered nothing and looked fine. `contentClient.get` asserts T, it does not verify it, so nothing caught any of this. Each fix is pinned by a test built from a payload copied verbatim off the live services. Crashed or printed "undefined": - search_suggest / search_spellcheck: both endpoints return a bare array of strings, not `{suggestions: [...]}`. Suggest threw on `.map`; spellcheck guarded the access and so reported "no suggestions" for every input. - entity_component_of: entries are one per relationship type, carrying parallel `names`/`stIds`/`schemaClasses` arrays -- not Complex objects. Every container printed "**undefined** (undefined) [undefined]", and the total counted relationship types rather than containers. - participants: the endpoint returns a reduced projection keyed on `peDbId`, with no `stId` and no `dbId`. - static_interactors / psicquic_details: `entities` lists the molecules queried; the interactors hang off each one. Reading score a level too high threw "Cannot read properties of undefined (reading 'toFixed')". - interactor_summary / psicquic_summary: same envelope, same level error. - analysis_found_entities: a mapsTo entry has `ids` (plural), not `identifier`. Silently dropped data, with no "undefined" to give it away: - participants never showed external identifiers: the endpoint returns `refEntities` (an array), never a singular `referenceEntity`. UniProt accessions are now rendered. - search_facets returned nothing but its heading. Each facet is an object with an `available` list, so `.length` was undefined and every section was skipped as falsy. Also: - events_hierarchy defaulted to species "Homo sapiens", which that endpoint answers with HTTP 500; "9606" returns 200. The default made the tool fail every time it was called without an explicit species. - The four interactor tools shared one envelope and four wrong copies of it. They now share one type and one formatter. Found by sweeping every reachable tool against the live services and diffing rendered output against the real payloads. 41 tests -> 57; 15 of the 16 new tests fail against the previous code. Co-Authored-By: Claude Opus 5 --- src/tools/analysis.ts | 2 +- src/tools/entity.ts | 63 +++++++-- src/tools/interactors.ts | 132 +++++++++--------- src/tools/pathway.ts | 6 +- src/tools/search.ts | 97 +++++++------ src/types/analysis.ts | 10 +- tests/formatters.test.ts | 285 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 481 insertions(+), 114 deletions(-) create mode 100644 tests/formatters.test.ts diff --git a/src/tools/analysis.ts b/src/tools/analysis.ts index 0efd32d..d431fed 100644 --- a/src/tools/analysis.ts +++ b/src/tools/analysis.ts @@ -168,7 +168,7 @@ export function registerAnalysisTools(server: McpServer) { "", "### Entities:", ...result.entities.map((e: FoundEntity) => - `- ${e.id} -> ${e.mapsTo.map(m => `${m.identifier} (${m.resource})`).join(", ")}` + `- ${e.id} -> ${e.mapsTo.map(m => `${(m.ids ?? []).join("/")} (${m.resource})`).join(", ")}` ), ]; diff --git a/src/tools/entity.ts b/src/tools/entity.ts index d5e7322..95a933f 100644 --- a/src/tools/entity.ts +++ b/src/tools/entity.ts @@ -3,12 +3,41 @@ import { z } from "zod"; import { contentClient } from "../clients/content.js"; import type { PhysicalEntity, Complex, ReferenceEntity, Event } from "../types/index.js"; +/** + * `/data/participants/{id}` returns a *reduced* projection, not a full + * PhysicalEntity. Verified against the live Content Service: + * + * {"displayName": "...", "peDbId": 109581, "schemaClass": "Complex", + * "refEntities": [{"dbId":..., "identifier": "Q15628", ...}]} + * + * There is no `stId`, no `dbId` and no singular `referenceEntity` -- reading + * those printed "(undefined)" and silently dropped every external identifier. + */ interface Participant { - dbId: number; - stId?: string; + peDbId: number; displayName: string; schemaClass: string; - referenceEntity?: ReferenceEntity; + refEntities?: ReferenceEntity[]; +} + +/** + * `/data/entity/{id}/componentOf` returns one entry per *relationship type*, + * each carrying parallel arrays of the containers reached by it: + * + * {"type": "hasEvent", "names": ["Programmed Cell Death"], + * "stIds": ["R-HSA-5357801"], "schemaClasses": ["TopLevelPathway"], + * "species": ["Homo sapiens"]} + * + * It is not a list of Complex objects -- there is no displayName/stId/ + * schemaClass on an entry, so the old formatting printed "**undefined** + * (undefined) [undefined]" for every container. + */ +interface ComponentOfEntry { + type: string; + names?: string[]; + stIds?: string[]; + schemaClasses?: string[]; + species?: string[]; } interface EnhancedEntity extends PhysicalEntity { @@ -159,17 +188,30 @@ export function registerEntityTools(server: McpServer) { id: z.string().max(2048).describe("Entity stable ID or database ID"), }, async ({ id }) => { - const containers = await contentClient.get(`/data/entity/${encodeURIComponent(id)}/componentOf`); + const containers = await contentClient.get(`/data/entity/${encodeURIComponent(id)}/componentOf`); + + // Each entry holds several containers, so the count people care about is + // the flattened one, not the number of relationship types. + const total = containers.reduce((n, c) => n + (c.stIds?.length ?? 0), 0); const lines = [ `## Structures Containing ${id}`, - `**Total:** ${containers.length}`, + `**Total:** ${total}`, "", - ...containers.slice(0, 50).map(c => `- **${c.displayName}** (${c.stId}) [${c.schemaClass}]`), + ...containers.flatMap(c => + // A componentOf entry is one *relationship* ("hasEvent", + // "hasComponent", ...) carrying parallel arrays of the containers + // reached by it -- names[i] pairs with stIds[i] and schemaClasses[i]. + // There is no singular displayName/stId/schemaClass on the entry. + (c.stIds ?? []).map((stId, i) => + `- **${c.names?.[i] ?? stId}** (${stId}) [${c.schemaClasses?.[i] ?? c.type}]` + ) + ), ]; - if (containers.length > 50) { - lines.push(`... and ${containers.length - 50} more structures`); + if (total > 50) { + lines.splice(53); + lines.push(`... and ${total - 50} more structures`); } return { @@ -205,8 +247,9 @@ export function registerEntityTools(server: McpServer) { Object.entries(byType).forEach(([type, entities]) => { lines.push(`### ${type} (${entities.length}):`); entities.slice(0, 20).forEach(e => { - const refInfo = e.referenceEntity ? ` [${e.referenceEntity.identifier}]` : ""; - lines.push(`- ${e.displayName} (${e.stId || e.dbId})${refInfo}`); + const ids = (e.refEntities ?? []).map(r => r.identifier).filter(Boolean); + const refInfo = ids.length > 0 ? ` [${ids.join(", ")}]` : ""; + lines.push(`- ${e.displayName} (${e.peDbId})${refInfo}`); }); if (entities.length > 20) { lines.push(`... and ${entities.length - 20} more`); diff --git a/src/tools/interactors.ts b/src/tools/interactors.ts index ecf97bc..139bb77 100644 --- a/src/tools/interactors.ts +++ b/src/tools/interactors.ts @@ -8,32 +8,64 @@ interface PsicquicResource { active: boolean; } -interface InteractorSummary { - accession: string; - count: number; +/** + * All four interactor endpoints -- psicquic summary/details and static + * summary/details -- return the SAME envelope. Verified against the live + * Content Service: + * + * GET /interactors/static/molecule/P04637/details + * {"resource": "static", + * "entities": [{"acc": "P04637", "count": 249, + * "interactors": [{"acc": "Q00987", "alias": "MDM2", + * "score": 0.995, "evidences": 122}]}]} + * + * `entities` is the list of molecules that were *queried*, not the list of + * interactors -- those hang off each entity. The previous types flattened the + * two levels and used `accession` where the API says `acc`, so the summary + * tools printed "undefined" for both protein and count, and the details tools + * crashed on `e.score.toFixed` because `score` lives one level down. + */ +interface InteractorEnvelope { + resource: string; + entities?: InteractorEntity[]; } -interface InteractionDetails { - accession: string; - entities: InteractionEntity[]; +interface InteractorEntity { + acc: string; + count: number; + interactors?: Interactor[]; } -interface InteractionEntity { - accession: string; +interface Interactor { + acc: string; score: number; - interactorId?: number; alias?: string; + evidences?: number; + id?: number; } -interface StaticInteractionDetails { - accession: string; - interactsWith: StaticInteractor[]; +/** + * Reduce the envelope to the single queried molecule. These tools always ask + * about one accession, so there is exactly one entity -- but the API still + * wraps it in an array, and an unknown accession yields an empty one. + */ +function firstEntity(result: InteractorEnvelope): InteractorEntity | undefined { + return result.entities?.[0]; } -interface StaticInteractor { - accession: string; - score: number; - chemicalId?: string; +function formatInteractors(interactors: Interactor[], limit = 30): string[] { + const lines = [...interactors] + .sort((a, b) => (b.score ?? 0) - (a.score ?? 0)) + .slice(0, limit) + .map(i => { + const score = typeof i.score === "number" ? i.score.toFixed(3) : "n/a"; + return `- **${i.acc}** (score: ${score})${i.alias ? ` - ${i.alias}` : ""}`; + }); + + if (interactors.length > limit) { + lines.push(`... and ${interactors.length - limit} more interactors`); + } + return lines; } export function registerInteractorTools(server: McpServer) { @@ -77,15 +109,16 @@ export function registerInteractorTools(server: McpServer) { accession: z.string().max(2048).describe("Protein accession (e.g., UniProt ID)"), }, async ({ resource, accession }) => { - const result = await contentClient.get( + const result = await contentClient.get( `/interactors/psicquic/molecule/${encodeURIComponent(resource)}/${encodeURIComponent(accession)}/summary` ); + const entity = firstEntity(result); const lines = [ `## PSICQUIC Interaction Summary`, - `**Protein:** ${result.accession}`, + `**Protein:** ${entity?.acc ?? accession}`, `**Resource:** ${resource}`, - `**Interaction count:** ${result.count}`, + `**Interaction count:** ${entity?.count ?? 0}`, ]; return { @@ -103,24 +136,23 @@ export function registerInteractorTools(server: McpServer) { accession: z.string().max(2048).describe("Protein accession"), }, async ({ resource, accession }) => { - const result = await contentClient.get( + const result = await contentClient.get( `/interactors/psicquic/molecule/${encodeURIComponent(resource)}/${encodeURIComponent(accession)}/details` ); + const entity = firstEntity(result); + const interactors = entity?.interactors ?? []; const lines = [ - `## PSICQUIC Interactions for ${result.accession}`, + `## PSICQUIC Interactions for ${entity?.acc ?? accession}`, `**Resource:** ${resource}`, - `**Interactors found:** ${result.entities.length}`, + `**Interactors found:** ${interactors.length}`, "", - "### Interacting Proteins (sorted by score):", - ...result.entities - .sort((a, b) => b.score - a.score) - .slice(0, 30) - .map(e => `- **${e.accession}** (score: ${e.score.toFixed(3)})${e.alias ? ` - ${e.alias}` : ""}`), ]; - if (result.entities.length > 30) { - lines.push(`... and ${result.entities.length - 30} more interactors`); + if (interactors.length > 0) { + lines.push("### Interacting Proteins (sorted by score):", ...formatInteractors(interactors)); + } else { + lines.push(`*No interactions found in ${resource}.*`); } return { @@ -137,36 +169,20 @@ export function registerInteractorTools(server: McpServer) { accession: z.string().max(2048).describe("Protein accession (e.g., UniProt ID)"), }, async ({ accession }) => { - interface StaticDetailsResult { - accession: string; - entities: Array<{ - acc: string; - score: number; - }>; - } - - const result = await contentClient.get( + const result = await contentClient.get( `/interactors/static/molecule/${encodeURIComponent(accession)}/details` ); + const entity = firstEntity(result); + const interactors = entity?.interactors ?? []; const lines = [ - `## Static Interactors for ${result.accession}`, - `**Interactors found:** ${result.entities?.length || 0}`, + `## Static Interactors for ${entity?.acc ?? accession}`, + `**Interactors found:** ${interactors.length}`, "", ]; - if (result.entities && result.entities.length > 0) { - lines.push("### Interacting Proteins:"); - result.entities - .sort((a, b) => b.score - a.score) - .slice(0, 30) - .forEach(e => { - lines.push(`- **${e.acc}** (score: ${e.score.toFixed(3)})`); - }); - - if (result.entities.length > 30) { - lines.push(`... and ${result.entities.length - 30} more interactors`); - } + if (interactors.length > 0) { + lines.push("### Interacting Proteins:", ...formatInteractors(interactors)); } else { lines.push("*No interactors found in the static database.*"); } @@ -214,18 +230,14 @@ export function registerInteractorTools(server: McpServer) { accession: z.string().max(2048).describe("Protein accession"), }, async ({ accession }) => { - interface SummaryResult { - accession: string; - count: number; - } - - const result = await contentClient.get( + const result = await contentClient.get( `/interactors/static/molecule/${encodeURIComponent(accession)}/summary` ); + const entity = firstEntity(result); const lines = [ - `## Interactor Summary for ${result.accession}`, - `**Total interactions:** ${result.count}`, + `## Interactor Summary for ${entity?.acc ?? accession}`, + `**Total interactions:** ${entity?.count ?? 0}`, ]; return { diff --git a/src/tools/pathway.ts b/src/tools/pathway.ts index 548a3d0..d50677b 100644 --- a/src/tools/pathway.ts +++ b/src/tools/pathway.ts @@ -249,7 +249,11 @@ export function registerPathwayTools(server: McpServer) { "reactome_events_hierarchy", "Get the complete event hierarchy (pathways and reactions tree) for a species. Warning: This returns a large data structure.", { - species: z.string().max(2048).optional().default("Homo sapiens").describe("Species name or taxonomy ID"), + // Defaults to the taxonomy ID, not the name: /data/eventsHierarchy + // returns HTTP 500 for "Homo sapiens" but 200 for "9606", so the + // previous default made this tool fail every time it was called without + // an explicit species. + species: z.string().max(2048).optional().default("9606").describe("Species taxonomy ID (e.g. 9606). Names are accepted by the API but are unreliable here -- prefer the ID."), }, async ({ species }) => { const hierarchy = await contentClient.get(`/data/eventsHierarchy/${encodeURIComponent(species)}`); diff --git a/src/tools/search.ts b/src/tools/search.ts index 497c522..ee3b0d5 100644 --- a/src/tools/search.ts +++ b/src/tools/search.ts @@ -3,13 +3,21 @@ import { z } from "zod"; import { contentClient } from "../clients/content.js"; import type { SearchResult, SearchEntry, FacetEntry } from "../types/index.js"; -interface SpellcheckResult { - suggestions: string[]; -} - -interface SuggestResult { - suggestions: string[]; -} +/** + * `/search/spellcheck` and `/search/suggest` return a BARE JSON array of + * strings -- not an object with a `suggestions` field. Verified against the + * live Content Service: + * + * GET /search/suggest?query=TP53 + * ["tp53:banp","tp53aip1","tp53b_human", ...] + * + * The previous `{ suggestions: string[] }` shape meant `result.suggestions` was + * always undefined: suggest crashed on `.map`, and spellcheck -- which guarded + * the access -- silently reported "no suggestions" for every input. + */ +type SpellcheckResult = string[]; + +type SuggestResult = string[]; interface PathwaySearchResult { dbId: number; @@ -162,14 +170,15 @@ export function registerSearchTools(server: McpServer) { }, async ({ query }) => { const result = await contentClient.get("/search/suggest", { query }); + const suggestions = Array.isArray(result) ? result : []; const lines = [ `## Suggestions for "${query}"`, "", - ...result.suggestions.map(s => `- ${s}`), + ...suggestions.map(s => `- ${s}`), ]; - if (result.suggestions.length === 0) { + if (suggestions.length === 0) { lines.push("*No suggestions found*"); } @@ -188,15 +197,16 @@ export function registerSearchTools(server: McpServer) { }, async ({ query }) => { const result = await contentClient.get("/search/spellcheck", { query }); + const suggestions = Array.isArray(result) ? result : []; const lines = [ `## Spellcheck for "${query}"`, "", ]; - if (result.suggestions && result.suggestions.length > 0) { + if (suggestions.length > 0) { lines.push("**Did you mean:**"); - lines.push(...result.suggestions.map(s => `- ${s}`)); + lines.push(...suggestions.map(s => `- ${s}`)); } else { lines.push("*No spelling suggestions*"); } @@ -215,11 +225,29 @@ export function registerSearchTools(server: McpServer) { query: z.string().max(2048).optional().describe("Search term (optional, returns global facets if omitted)"), }, async ({ query }) => { + /** + * Each facet is an OBJECT with an `available` list, not a bare array. + * Verified against the live Content Service: + * + * GET /search/facet + * {"totalNumFount": 388394, + * "typeFacet": {"available": [{"name": "Complex", "count": 111374}]}} + * + * Treating them as arrays meant `.length` was undefined, every section + * was skipped as falsy, and the tool returned nothing but its heading -- + * successfully, so nothing ever flagged it. + */ + interface Facet { + available?: FacetEntry[]; + selected?: FacetEntry[]; + } + interface FacetResult { - typeFacet?: FacetEntry[]; - speciesFacet?: FacetEntry[]; - compartmentFacet?: FacetEntry[]; - keywordFacet?: FacetEntry[]; + totalNumFount?: number; + typeFacet?: Facet; + speciesFacet?: Facet; + compartmentFacet?: Facet; + keywordFacet?: Facet; } const endpoint = query ? "/search/facet_query" : "/search/facet"; @@ -229,38 +257,25 @@ export function registerSearchTools(server: McpServer) { const lines = [ query ? `## Facets for "${query}"` : "## Available Search Facets", + ...(result.totalNumFount !== undefined ? [`**Matching entries:** ${result.totalNumFount}`] : []), "", ]; - if (result.typeFacet && result.typeFacet.length > 0) { - lines.push("### Types:"); - result.typeFacet.slice(0, 15).forEach(f => { - lines.push(`- ${f.name}: ${f.count}`); - }); - lines.push(""); - } - - if (result.speciesFacet && result.speciesFacet.length > 0) { - lines.push("### Species:"); - result.speciesFacet.slice(0, 10).forEach(f => { - lines.push(`- ${f.name}: ${f.count}`); - }); + const section = (heading: string, facet: Facet | undefined, limit: number) => { + const entries = facet?.available ?? []; + if (entries.length === 0) return; + lines.push(`### ${heading}:`); + entries.slice(0, limit).forEach(f => lines.push(`- ${f.name}: ${f.count}`)); lines.push(""); - } + }; - if (result.compartmentFacet && result.compartmentFacet.length > 0) { - lines.push("### Compartments:"); - result.compartmentFacet.slice(0, 10).forEach(f => { - lines.push(`- ${f.name}: ${f.count}`); - }); - lines.push(""); - } + section("Types", result.typeFacet, 15); + section("Species", result.speciesFacet, 10); + section("Compartments", result.compartmentFacet, 10); + section("Keywords", result.keywordFacet, 10); - if (result.keywordFacet && result.keywordFacet.length > 0) { - lines.push("### Keywords:"); - result.keywordFacet.slice(0, 10).forEach(f => { - lines.push(`- ${f.name}: ${f.count}`); - }); + if (!lines.some(l => l.startsWith("###"))) { + lines.push("*No facets available.*"); } return { diff --git a/src/types/analysis.ts b/src/types/analysis.ts index b02d953..14e0b37 100644 --- a/src/types/analysis.ts +++ b/src/types/analysis.ts @@ -88,9 +88,17 @@ export interface FoundEntity { exp?: number[]; } +/** + * A mapsTo entry groups the resource identifiers one submitted id resolved to. + * Verified against the live Analysis Service: + * + * GET /token/{token}/found/all/{pathway} + * {"id": "TP53", "exp": [], "mapsTo": [{"resource": "UNIPROT", "ids": ["P04637"]}]} + * + * There is no singular `identifier` -- reading it rendered "TP53 -> undefined". + */ export interface MappedEntity { resource: string; - identifier: string; ids: string[]; } diff --git a/tests/formatters.test.ts b/tests/formatters.test.ts new file mode 100644 index 0000000..cc3eff3 --- /dev/null +++ b/tests/formatters.test.ts @@ -0,0 +1,285 @@ +/** + * Regression tests for response *shapes*. + * + * Every payload below is copied verbatim from the live Reactome services. The + * bugs these pin were not logic errors -- the code read field paths that the + * API never returned, so the tools rendered "undefined" (or, worse, silently + * rendered nothing at all) while still reporting success. Type annotations + * alone could not catch that: `contentClient.get` asserts T, it does not + * verify it. + * + * If a payload here stops matching production, that is the signal to update + * the formatter -- not the fixture. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createFakeServer } from "./helpers/fake-server.js"; + +import { registerSearchTools } from "../src/tools/search.js"; +import { registerEntityTools } from "../src/tools/entity.js"; +import { registerInteractorTools } from "../src/tools/interactors.js"; +import { registerAnalysisTools } from "../src/tools/analysis.js"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +describe("response shape regressions", () => { + let fetchSpy: ReturnType; + const fake = createFakeServer(); + registerSearchTools(fake.server); + registerEntityTools(fake.server); + registerInteractorTools(fake.server); + registerAnalysisTools(fake.server); + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, "fetch"); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + // GET /search/suggest?query=TP53 + it("reactome_search_suggest reads the bare array the API returns", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(["tp53:banp", "tp53aip1", "tp53bp1"])); + + const result = await fake.invoke("reactome_search_suggest", { query: "TP53" }); + const text = result.content[0].text; + + expect(text).toContain("- tp53:banp"); + expect(text).toContain("- tp53bp1"); + expect(text).not.toContain("undefined"); + expect(text).not.toContain("No suggestions found"); + }); + + it("reactome_search_suggest reports emptiness rather than crashing on a non-array", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ suggestions: [] })); + + const result = await fake.invoke("reactome_search_suggest", { query: "TP53" }); + expect(result.content[0].text).toContain("No suggestions found"); + }); + + // GET /search/spellcheck?query=kinse + it("reactome_search_spellcheck reads the bare array the API returns", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(["kinase", "kinases", "kinae"])); + + const text = (await fake.invoke("reactome_search_spellcheck", { query: "kinse" })).content[0].text; + + // The old code guarded `result.suggestions`, so this branch was + // unreachable: every spellcheck reported "no suggestions". + expect(text).toContain("**Did you mean:**"); + expect(text).toContain("- kinase"); + expect(text).not.toContain("No spelling suggestions"); + }); + + // GET /data/entity/R-HSA-109581/componentOf + it("reactome_entity_component_of expands the parallel-array entries", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse([ + { + type: "hasEvent", + names: ["Programmed Cell Death", "Apoptosis"], + stIds: ["R-HSA-5357801", "R-HSA-109581"], + schemaClasses: ["TopLevelPathway", "Pathway"], + species: ["Homo sapiens", "Homo sapiens"], + }, + ]) + ); + + const text = (await fake.invoke("reactome_entity_component_of", { id: "R-HSA-109581" })).content[0].text; + + // One entry, two containers -- the count is of containers, not entries. + expect(text).toContain("**Total:** 2"); + expect(text).toContain("**Programmed Cell Death** (R-HSA-5357801) [TopLevelPathway]"); + expect(text).toContain("**Apoptosis** (R-HSA-109581) [Pathway]"); + expect(text).not.toContain("undefined"); + }); + + it("reactome_entity_component_of falls back when names are absent", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse([{ type: "hasComponent", stIds: ["R-HSA-1"] }]) + ); + + const text = (await fake.invoke("reactome_entity_component_of", { id: "R-HSA-1" })).content[0].text; + expect(text).toContain("(R-HSA-1) [hasComponent]"); + expect(text).not.toContain("undefined"); + }); + + // GET /data/participants/R-HSA-109581 + it("reactome_participants reads peDbId and refEntities", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse([ + { + displayName: "TRADD:TRAF2:RIP1:FADD:CASP8(1-479) [cytosol]", + peDbId: 140976, + schemaClass: "Complex", + refEntities: [ + { dbId: 1, identifier: "Q12933", displayName: "TRAF2", schemaClass: "ReferenceGeneProduct" }, + { dbId: 2, identifier: "Q15628", displayName: "TRADD", schemaClass: "ReferenceGeneProduct" }, + ], + }, + ]) + ); + + const text = (await fake.invoke("reactome_participants", { id: "R-HSA-109581" })).content[0].text; + + expect(text).toContain("(140976)"); + // Identifiers were dropped entirely before: the endpoint returns + // `refEntities` (plural, an array), never a singular `referenceEntity`. + expect(text).toContain("[Q12933, Q15628]"); + expect(text).not.toContain("undefined"); + }); + + it("reactome_participants omits the bracket when a participant has no references", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse([{ displayName: "ATP [cytosol]", peDbId: 113592, schemaClass: "SimpleEntity" }]) + ); + + const text = (await fake.invoke("reactome_participants", { id: "R-HSA-1" })).content[0].text; + expect(text).toContain("- ATP [cytosol] (113592)"); + expect(text).not.toContain("undefined"); + }); + + // GET /search/facet + it("reactome_search_facets reads facet.available, not the facet itself", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + totalNumFount: 388394, + typeFacet: { available: [{ name: "Complex", count: 111374 }] }, + speciesFacet: { available: [{ name: "Homo sapiens", count: 57354 }] }, + compartmentFacet: { available: [{ name: "cytosol", count: 94077 }] }, + keywordFacet: { available: [{ name: "binds", count: 20173 }] }, + }) + ); + + const text = (await fake.invoke("reactome_search_facets", {})).content[0].text; + + // Every section was silently skipped before: a facet is an object with an + // `available` array, so reading `.length` on it gave undefined. + expect(text).toContain("### Types:"); + expect(text).toContain("- Complex: 111374"); + expect(text).toContain("### Species:"); + expect(text).toContain("- Homo sapiens: 57354"); + expect(text).toContain("### Compartments:"); + expect(text).toContain("### Keywords:"); + expect(text).toContain("388394"); + expect(text).not.toContain("No facets available"); + }); + + it("reactome_search_facets says so when there are no facets", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ totalNumFount: 0 })); + + const text = (await fake.invoke("reactome_search_facets", {})).content[0].text; + expect(text).toContain("*No facets available.*"); + }); + + // GET /interactors/static/molecule/P04637/details + const interactorEnvelope = { + resource: "static", + entities: [ + { + acc: "P04637", + count: 249, + interactors: [ + { acc: "Q00987", alias: "MDM2", id: 10089669, evidences: 122, score: 0.995 }, + { acc: "P38936", alias: "CDKN1A", id: 10089670, evidences: 40, score: 0.8 }, + ], + }, + ], + }; + + it("reactome_static_interactors descends into entities[].interactors", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse(interactorEnvelope)); + + const text = (await fake.invoke("reactome_static_interactors", { accession: "P04637" })).content[0].text; + + // `entities` lists the molecules queried, not the interactors -- reading + // score off it threw "Cannot read properties of undefined (reading 'toFixed')". + expect(text).toContain("Static Interactors for P04637"); + expect(text).toContain("**Interactors found:** 2"); + expect(text).toContain("**Q00987** (score: 0.995) - MDM2"); + expect(text).not.toContain("undefined"); + }); + + it("reactome_static_interactors survives an interactor with no score", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ resource: "static", entities: [{ acc: "P04637", count: 1, interactors: [{ acc: "Q00987" }] }] }) + ); + + const text = (await fake.invoke("reactome_static_interactors", { accession: "P04637" })).content[0].text; + expect(text).toContain("(score: n/a)"); + expect(text).not.toContain("undefined"); + }); + + it("reactome_static_interactors handles an unknown accession", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ resource: "static", entities: [] })); + + const text = (await fake.invoke("reactome_static_interactors", { accession: "NOPE" })).content[0].text; + expect(text).toContain("Static Interactors for NOPE"); + expect(text).toContain("*No interactors found in the static database.*"); + expect(text).not.toContain("undefined"); + }); + + // GET /interactors/static/molecule/P04637/summary + it("reactome_interactor_summary reads entities[0]", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ resource: "static", entities: [{ acc: "P04637", count: 249 }] }) + ); + + const text = (await fake.invoke("reactome_interactor_summary", { accession: "P04637" })).content[0].text; + expect(text).toContain("Interactor Summary for P04637"); + expect(text).toContain("**Total interactions:** 249"); + expect(text).not.toContain("undefined"); + }); + + // GET /interactors/psicquic/molecule/IntAct/P04637/details -- same envelope + it("reactome_psicquic_details uses the same envelope as the static endpoint", async () => { + fetchSpy.mockResolvedValueOnce(jsonResponse({ ...interactorEnvelope, resource: "IntAct" })); + + const text = ( + await fake.invoke("reactome_psicquic_details", { resource: "IntAct", accession: "P04637" }) + ).content[0].text; + + expect(text).toContain("**Interactors found:** 2"); + expect(text).toContain("**Q00987** (score: 0.995) - MDM2"); + expect(text).not.toContain("undefined"); + }); + + it("reactome_psicquic_summary reads entities[0]", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ resource: "IntAct", entities: [{ acc: "P04637", count: 144 }] }) + ); + + const text = ( + await fake.invoke("reactome_psicquic_summary", { resource: "IntAct", accession: "P04637" }) + ).content[0].text; + + expect(text).toContain("**Protein:** P04637"); + expect(text).toContain("**Interaction count:** 144"); + expect(text).not.toContain("undefined"); + }); + + // GET /token/{token}/found/all/{pathway} + it("reactome_analysis_found_entities renders mapsTo.ids", async () => { + fetchSpy.mockResolvedValueOnce( + jsonResponse({ + pathway: "R-HSA-109581", + foundEntities: 1, + foundInteractors: 0, + entities: [{ id: "TP53", exp: [], mapsTo: [{ resource: "UNIPROT", ids: ["P04637"] }] }], + interactors: [], + }) + ); + + const text = ( + await fake.invoke("reactome_analysis_found_entities", { token: "tok", pathway: "R-HSA-109581" }) + ).content[0].text; + + // A mapsTo entry has `ids` (plural); there is no singular `identifier`. + expect(text).toContain("- TP53 -> P04637 (UNIPROT)"); + expect(text).not.toContain("undefined"); + }); +});