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"); + }); +});