From 06da09199e24bfc30027b69428627415074ddc95 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 23:13:12 +0000 Subject: [PATCH 1/2] fix(calendar): detect degraded get_calendar_events sentinel so web fallback fires The riksdag-regering MCP get_calendar_events tool returns a "successful" empty events:[] result with an error/rawHtml sentinel when upstream data.riksdagen.se/kalender/ serves HTML. The client read this as a legitimate zero-event window, masking the outage and suppressing the web-scraper fallback. Add isDegradedKalenderSentinel detection in both calendar clients (throw CalendarMcpError 'html' so orchestrator falls back), plus regression tests and agentic guidance steering agents to the resilient scripts/calendar-fetch.ts CLI / data/runtime/calendar-status.json. Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- .github/prompts/02-mcp-access.md | 1 + analysis/templates/session-baseline.md | 10 ++++- scripts/fetch-calendar.ts | 1 + scripts/fetch-calendar/mcp/client.ts | 20 ++++++++- scripts/fetch-calendar/mcp/errors.ts | 27 ++++++++++++ scripts/mcp-client/methods/calendar.ts | 19 ++++++++ tests/fetch-calendar/mcp/client.test.ts | 44 +++++++++++++++++++ tests/fetch-calendar/mcp/errors.test.ts | 34 ++++++++++++++ .../orchestrator-integration.test.ts | 41 +++++++++++++++++ tests/mcp-client/methods/calendar.test.ts | 32 ++++++++++++++ 10 files changed, 226 insertions(+), 3 deletions(-) diff --git a/.github/prompts/02-mcp-access.md b/.github/prompts/02-mcp-access.md index d9a3ba8f48d..f66a98dd952 100644 --- a/.github/prompts/02-mcp-access.md +++ b/.github/prompts/02-mcp-access.md @@ -76,6 +76,7 @@ Run once at workflow start, then proceed: | **Statskontoret is a public non-MCP source.** Use `web_fetch` / primary URLs, cite report title + URL, record retrieval in `data-download-manifest.md`. | | **Lagrådet is a public non-MCP source.** Required for major-bill propositions per [`03-data-download.md §Lagrådet enrichment`](03-data-download.md). Cite referral URL + yttrande publication date; tag `referral pending` when no yttrande yet exists. | | **Prior-voteringar enrichment** is standard: `search_voteringar` keyed by **topic keyword** (`avser`) or **full proposition beteckning** (e.g. `bet: "2024/25:JuU17"`, never a bare committee prefix like `JuU`) over the last 4 `rm` (riksmöten), for every committee-report, motion, interpellation cycle. Feeds `historical-parallels.md`, `coalition-mathematics.md`, `swot-analysis.md` evidence rows. See [`03-data-download.md §Prior-voteringar enrichment`](03-data-download.md) for the full query-shape contract and fallback hierarchy. | +| **Calendar is fetched via the resilient CLI, not the raw tool.** The raw `get_calendar_events` MCP tool returns a *successful* empty `events: []` result (with an `error`/`rawHtml` sentinel) when `data.riksdagen.se/kalender/` serves HTML — silently masking the outage as a zero-sitting week. Source the forward calendar from the pre-warmed **`data/runtime/calendar-status.json`** (written by `news-prewarm`) and/or **`npx tsx scripts/calendar-fetch.ts --from --to `**, which falls back MCP→public-page scraper and reports `status` (`ok`/`error`) + `path` (`mcp-primary`/`web-fallback`/`none`). If `status: error` / `path: none`, record a `[DATA GAP: calendar source degraded]` in `data-download-manifest.md` — never assert an empty sitting calendar from the raw tool. | | Treat mid-run MCP failure as partial data: continue with what you have, document gaps in `data-download-manifest.md`, never silently drop documents. | | Source authority and no-fabrication rule: see [`00-base-contract.md`](00-base-contract.md) rules 1 + 3. | diff --git a/analysis/templates/session-baseline.md b/analysis/templates/session-baseline.md index 31708082f59..41625fec4b8 100644 --- a/analysis/templates/session-baseline.md +++ b/analysis/templates/session-baseline.md @@ -16,6 +16,12 @@ - **Cross-reference baseline** — Every `dok_id` cited here must resolve via `get_dokument`; every MP reference must carry an `intressent_id` - **MCP tools** — `get_calendar_events`, `search_dokument`, `search_voteringar`, `search_anforanden`, `get_betankanden`, `get_propositioner`, `get_motioner`, `get_fragor`, `get_interpellationer`, `search_regering` +> **📅 Calendar sourcing (degraded-source guard)** — The raw `get_calendar_events` MCP tool is **brittle**: when `data.riksdagen.se/kalender/` serves an HTML error page the server still returns a *successful* result with an empty `events: []` array plus an `error`/`rawHtml` sentinel, which silently reads as a legitimate zero-event window. **Do not** treat an empty `get_calendar_events` result as ground truth. Prefer, in order: +> 1. The pre-warmed artifact **`data/runtime/calendar-status.json`** (written by the `news-prewarm` action) — check its `status` (`ok`/`error`) and `path` (`mcp-primary`/`web-fallback`/`none`) fields. +> 2. The resilient CLI **`scripts/calendar-fetch.ts --from --to `**, which falls back from MCP to the public-page scraper and reports `status`/`path` honestly. +> +> If both report `status: error` / `path: none`, record the calendar as a **DATA GAP** (`[DATA GAP: calendar source degraded]`) and log it in `mcp-reliability-audit.md §Failure analysis` — never fabricate a zero-sitting week. + --- @@ -76,7 +82,7 @@ ANTI-TEMPLATE — DO NOT: ## 1️⃣ Kammaren (Plenary Sittings) -> Record each sitting separately. Populate from `get_calendar_events` (org=kammaren) and `search_dokument`. +> Record each sitting separately. Populate from `get_calendar_events` (org=kammaren) and `search_dokument` — see the **📅 Calendar sourcing** guard above; prefer `data/runtime/calendar-status.json` / `scripts/calendar-fetch.ts` over the raw tool. ### Sitting 1 — `[REQUIRED: date]` @@ -106,7 +112,7 @@ ANTI-TEMPLATE — DO NOT: ## 2️⃣ Utskott (Committee) Sessions -> Populate from `get_calendar_events` (org=UTSK), `get_betankanden`, and direct committee-page queries. +> Populate from `get_calendar_events` (org=UTSK), `get_betankanden`, and direct committee-page queries — see the **📅 Calendar sourcing** guard above; prefer `data/runtime/calendar-status.json` / `scripts/calendar-fetch.ts` over the raw tool. | Utskott | Datum | Tid | Typ (beslutsmöte/sammanträde) | Dagordning | Antagna betänkanden | Ordförande | Party | Source | |---------|:-----:|:---:|:-----------------------------:|------------|:-------------------:|------------|:-----:|--------| diff --git a/scripts/fetch-calendar.ts b/scripts/fetch-calendar.ts index e6debd372ee..b770fb612aa 100644 --- a/scripts/fetch-calendar.ts +++ b/scripts/fetch-calendar.ts @@ -32,6 +32,7 @@ export type { export { CalendarMcpError, HTML_PREFIX_RE, + isDegradedKalenderSentinel, isHtmlErrorResponse, } from './fetch-calendar/mcp/errors.js'; export { callMcpCalendarEvents } from './fetch-calendar/mcp/client.js'; diff --git a/scripts/fetch-calendar/mcp/client.ts b/scripts/fetch-calendar/mcp/client.ts index 9e9df048b60..1410e3870d8 100644 --- a/scripts/fetch-calendar/mcp/client.ts +++ b/scripts/fetch-calendar/mcp/client.ts @@ -12,7 +12,7 @@ */ import type { CalendarFetchConfig } from '../types.js'; -import { CalendarMcpError, isHtmlErrorResponse } from './errors.js'; +import { CalendarMcpError, isDegradedKalenderSentinel, isHtmlErrorResponse } from './errors.js'; export const DEFAULT_MCP_URL = process.env['MCP_SERVER_URL'] ?? 'https://riksdag-regering-ai.onrender.com/mcp'; @@ -125,11 +125,29 @@ export async function callMcpCalendarEvents( 'json', ); } + // The server wraps an upstream HTML error in a "successful" envelope with + // an empty events array — detect it so the orchestrator falls back to the + // web scraper instead of trusting a fake zero-event window. + if (isDegradedKalenderSentinel(inner)) { + throw new CalendarMcpError( + `MCP kalender API degraded: ${String(inner['error'] ?? 'upstream HTML error')}`, + 'html', + typeof inner['rawHtml'] === 'string' ? inner['rawHtml'] : undefined, + ); + } const events = inner['kalender'] ?? inner['events']; if (Array.isArray(events)) return events as unknown[]; return []; } + if (isDegradedKalenderSentinel(result)) { + throw new CalendarMcpError( + `MCP kalender API degraded: ${String(result['error'] ?? 'upstream HTML error')}`, + 'html', + typeof result['rawHtml'] === 'string' ? result['rawHtml'] : undefined, + ); + } + const direct = result['kalender'] ?? result['events']; if (Array.isArray(direct)) return direct as unknown[]; diff --git a/scripts/fetch-calendar/mcp/errors.ts b/scripts/fetch-calendar/mcp/errors.ts index 8b583b5552f..61b71d76d6f 100644 --- a/scripts/fetch-calendar/mcp/errors.ts +++ b/scripts/fetch-calendar/mcp/errors.ts @@ -24,6 +24,33 @@ export function isHtmlErrorResponse(text: string): boolean { return HTML_PREFIX_RE.test(text); } +/** + * Detect the riksdag-regering **degraded-kalender sentinel** payload. + * + * When the upstream `data.riksdagen.se/kalender/` endpoint serves an HTML + * error page instead of JSON, the MCP server does not surface a JSON-RPC + * error — it returns a *successful* tool result whose inner content is a + * sentinel envelope such as: + * + * ```json + * { "count": 0, "events": [], "rawHtml": "): boolean { + const hasErrorString = typeof inner['error'] === 'string' && inner['error'].trim().length > 0; + const hasRawHtml = typeof inner['rawHtml'] === 'string' && inner['rawHtml'].trim().length > 0; + return hasErrorString || hasRawHtml; +} + /** Typed error for MCP transport / protocol failures. */ export class CalendarMcpError extends Error { /** Error category. */ diff --git a/scripts/mcp-client/methods/calendar.ts b/scripts/mcp-client/methods/calendar.ts index 599a78e9d6b..aefaa760bfe 100644 --- a/scripts/mcp-client/methods/calendar.ts +++ b/scripts/mcp-client/methods/calendar.ts @@ -11,6 +11,13 @@ import type { MCPTransportClient } from '../transport/jsonrpc.js'; /** * Fetch parliamentary calendar events between two dates, optionally * filtered by organ (committee) and aktivitet (event type). + * + * Throws when the riksdag-regering server returns its degraded-kalender + * sentinel (an empty `events` array alongside an `error`/`rawHtml` field, + * emitted when `data.riksdagen.se/kalender/` serves an HTML error page). + * Surfacing the failure lets callers fall back to the public-page scraper + * via {@link module:scripts/fetch-calendar} instead of trusting a fake + * zero-event window. */ export async function fetchCalendarEvents( transport: MCPTransportClient, @@ -24,5 +31,17 @@ export async function fetchCalendarEvents( if (akt) params['akt'] = akt; const response = await transport.request('get_calendar_events', params); + + const errorText = response['error']; + const rawHtml = response['rawHtml']; + const isDegraded = + (typeof errorText === 'string' && errorText.trim().length > 0) || + (typeof rawHtml === 'string' && rawHtml.trim().length > 0); + if (isDegraded) { + throw new Error( + `get_calendar_events degraded: ${typeof errorText === 'string' ? errorText : 'upstream HTML error from data.riksdagen.se/kalender/'}`, + ); + } + return (response['kalender'] ?? response['events'] ?? []) as unknown[]; } diff --git a/tests/fetch-calendar/mcp/client.test.ts b/tests/fetch-calendar/mcp/client.test.ts index 84caff79f86..2bf3e81dba7 100644 --- a/tests/fetch-calendar/mcp/client.test.ts +++ b/tests/fetch-calendar/mcp/client.test.ts @@ -172,4 +172,48 @@ describe('callMcpCalendarEvents', () => { const result = await callMcpCalendarEvents('2026-04-28', '2026-05-04', config); expect(result).toEqual([]); }); + + it('throws CalendarMcpError(html) on the degraded-kalender sentinel in content text', async () => { + // The server wraps an upstream HTML error in a "successful" envelope whose + // empty events array must NOT be read as a legitimate zero-event window. + const sentinel = { + count: 0, + events: [], + rawHtml: '', + error: 'Riksdagens kalender-API returnerade HTML istället för JSON.', + }; + const config = { + mcpUrl: 'https://mcp.test/mcp', + timeout: 3_000, + fetchFn: jsonFetch({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ text: JSON.stringify(sentinel) }] }, + }), + }; + + await expect( + callMcpCalendarEvents('2026-04-28', '2026-05-04', config), + ).rejects.toMatchObject({ kind: 'html' }); + }); + + it('throws CalendarMcpError(html) on a degraded sentinel in a direct result (no content wrapper)', async () => { + const config = { + mcpUrl: 'https://mcp.test/mcp', + timeout: 3_000, + fetchFn: jsonFetch({ + jsonrpc: '2.0', + id: 1, + result: { + count: 0, + events: [], + error: 'Riksdagens kalender-API returnerade HTML istället för JSON.', + }, + }), + }; + + await expect( + callMcpCalendarEvents('2026-04-28', '2026-05-04', config), + ).rejects.toMatchObject({ kind: 'html' }); + }); }); diff --git a/tests/fetch-calendar/mcp/errors.test.ts b/tests/fetch-calendar/mcp/errors.test.ts index 60e529a196b..6fae1bd082b 100644 --- a/tests/fetch-calendar/mcp/errors.test.ts +++ b/tests/fetch-calendar/mcp/errors.test.ts @@ -12,6 +12,7 @@ import { describe, it, expect } from 'vitest'; import { isHtmlErrorResponse, + isDegradedKalenderSentinel, CalendarMcpError, HTML_PREFIX_RE, } from '../../../scripts/fetch-calendar.js'; @@ -83,6 +84,39 @@ describe('HTML_PREFIX_RE (regex guard against future drift)', () => { }); }); +describe('isDegradedKalenderSentinel', () => { + it('returns true when an upstream error string is present', () => { + expect( + isDegradedKalenderSentinel({ + count: 0, + events: [], + error: 'Riksdagens kalender-API returnerade HTML istället för JSON.', + }), + ).toBe(true); + }); + + it('returns true when a rawHtml field is present', () => { + expect( + isDegradedKalenderSentinel({ count: 0, events: [], rawHtml: '' }), + ).toBe(true); + }); + + it('returns false for a legitimate empty calendar window', () => { + expect(isDegradedKalenderSentinel({ count: 0, events: [] })).toBe(false); + expect(isDegradedKalenderSentinel({ kalender: [] })).toBe(false); + }); + + it('returns false when error/rawHtml are empty or whitespace', () => { + expect(isDegradedKalenderSentinel({ events: [], error: '' })).toBe(false); + expect(isDegradedKalenderSentinel({ events: [], rawHtml: ' ' })).toBe(false); + }); + + it('returns false when error/rawHtml are non-string types', () => { + expect(isDegradedKalenderSentinel({ events: [], error: null })).toBe(false); + expect(isDegradedKalenderSentinel({ events: [], rawHtml: 0 })).toBe(false); + }); +}); + describe('CalendarMcpError', () => { it('has the correct name and kind', () => { const err = new CalendarMcpError('test error', 'html', 'error'); diff --git a/tests/fetch-calendar/orchestrator-integration.test.ts b/tests/fetch-calendar/orchestrator-integration.test.ts index de8014757b5..9b265638a74 100644 --- a/tests/fetch-calendar/orchestrator-integration.test.ts +++ b/tests/fetch-calendar/orchestrator-integration.test.ts @@ -138,6 +138,47 @@ describe('fetchCalendarWithFallback', () => { expect(callCount).toBeGreaterThanOrEqual(2); }); + it('triggers web fallback when MCP returns the degraded-kalender sentinel', async () => { + // The riksdag-regering server wraps an upstream HTML outage in a + // "successful" tool result with an empty events array plus an `error` + // sentinel. The orchestrator must treat this as a primary failure and + // fall back to the public-page scraper instead of recording a fake + // zero-event window as a successful mcp-primary result. + const sentinel = { + count: 0, + events: [], + rawHtml: '', + error: 'Riksdagens kalender-API returnerade HTML istället för JSON.', + }; + const webHtml = ` +
+ +

Utrikespolitik

+
+ `; + + const fetchFn = vi.fn(async (url: RequestInfo | URL) => { + const hostname = (() => { try { return new URL(String(url)).hostname; } catch { return ''; } })(); + if (hostname === 'mcp.test') { + return new Response(JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ text: JSON.stringify(sentinel) }] }, + }), { status: 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(webHtml, { status: 200, headers: { 'Content-Type': 'text/html' } }); + }) as unknown as typeof fetch; + + const config = makeConfig({ fetchFn, sleepFn }); + const result = await fetchCalendarWithFallback('2026-04-28', '2026-05-04', config); + + expect(result.manifest.path).toBe('web-fallback'); + expect(result.manifest.primaryError).toMatch(/degraded|HTML/i); + expect(result.events).toHaveLength(1); + expect(result.events[0]?.source).toBe('web-fallback'); + expect(result.events[0]?.org).toBe('UU'); + }); + it('retries MCP on network error before falling back', async () => { const webHtml = `
diff --git a/tests/mcp-client/methods/calendar.test.ts b/tests/mcp-client/methods/calendar.test.ts index 2fd4ac1bf66..ebcf7745056 100644 --- a/tests/mcp-client/methods/calendar.test.ts +++ b/tests/mcp-client/methods/calendar.test.ts @@ -78,4 +78,36 @@ describe('methods/calendar — fetchCalendarEvents', () => { const events = await client.fetchCalendarEvents('2026-02-10', '2026-02-17'); expect(events).toEqual([]); }); + + it('should throw when the server returns the degraded-kalender sentinel', async () => { + // Upstream data.riksdagen.se/kalender/ served HTML: the MCP server wraps + // this in a "successful" result with an empty events array plus an error + // field. Returning [] would mask the outage, so the method must throw. + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + jsonrpc: '2.0', + id: 1, + result: { + content: [ + { + text: JSON.stringify({ + count: 0, + events: [], + rawHtml: '', + error: 'Riksdagens kalender-API returnerade HTML istället för JSON.', + }), + }, + ], + }, + }), + }), + ) as unknown as typeof global.fetch; + + await expect( + client.fetchCalendarEvents('2026-02-10', '2026-02-17'), + ).rejects.toThrow(/degraded/i); + }); }); From 4328f85492ec0f00d13aca19ba32de47f2b5fb80 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 06:07:31 +0000 Subject: [PATCH 2/2] refactor(calendar): reuse isDegradedKalenderSentinel in mcp-client, expand test coverage - Refactored scripts/mcp-client/methods/calendar.ts to import and use the shared isDegradedKalenderSentinel() from fetch-calendar/mcp/errors.ts instead of duplicating the detection logic inline. - Added 9 new test cases across 3 test files: - errors.test.ts: both fields present, rawHtml-only, empty object, additional non-string type variants - client.test.ts: malformed JSON content, rawHtml in responseText, error message propagation, rawHtml-only sentinel - calendar.test.ts: rawHtml-only sentinel, error message verification Co-authored-by: pethers <1726836+pethers@users.noreply.github.com> --- scripts/mcp-client/methods/calendar.ts | 9 +-- tests/fetch-calendar/mcp/client.test.ts | 77 +++++++++++++++++++++++ tests/fetch-calendar/mcp/errors.test.ts | 26 ++++++++ tests/mcp-client/methods/calendar.test.ts | 45 +++++++++++++ 4 files changed, 151 insertions(+), 6 deletions(-) diff --git a/scripts/mcp-client/methods/calendar.ts b/scripts/mcp-client/methods/calendar.ts index aefaa760bfe..4083eddfac4 100644 --- a/scripts/mcp-client/methods/calendar.ts +++ b/scripts/mcp-client/methods/calendar.ts @@ -7,6 +7,7 @@ */ import type { MCPTransportClient } from '../transport/jsonrpc.js'; +import { isDegradedKalenderSentinel } from '../../fetch-calendar/mcp/errors.js'; /** * Fetch parliamentary calendar events between two dates, optionally @@ -32,12 +33,8 @@ export async function fetchCalendarEvents( const response = await transport.request('get_calendar_events', params); - const errorText = response['error']; - const rawHtml = response['rawHtml']; - const isDegraded = - (typeof errorText === 'string' && errorText.trim().length > 0) || - (typeof rawHtml === 'string' && rawHtml.trim().length > 0); - if (isDegraded) { + if (isDegradedKalenderSentinel(response)) { + const errorText = response['error']; throw new Error( `get_calendar_events degraded: ${typeof errorText === 'string' ? errorText : 'upstream HTML error from data.riksdagen.se/kalender/'}`, ); diff --git a/tests/fetch-calendar/mcp/client.test.ts b/tests/fetch-calendar/mcp/client.test.ts index 2bf3e81dba7..6cf2ccececc 100644 --- a/tests/fetch-calendar/mcp/client.test.ts +++ b/tests/fetch-calendar/mcp/client.test.ts @@ -216,4 +216,81 @@ describe('callMcpCalendarEvents', () => { callMcpCalendarEvents('2026-04-28', '2026-05-04', config), ).rejects.toMatchObject({ kind: 'html' }); }); + + it('throws CalendarMcpError(json) when content text is malformed JSON', async () => { + const config = { + mcpUrl: 'https://mcp.test/mcp', + timeout: 3_000, + fetchFn: jsonFetch({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ text: '{not valid json' }] }, + }), + }; + + await expect( + callMcpCalendarEvents('2026-04-28', '2026-05-04', config), + ).rejects.toMatchObject({ kind: 'json' }); + }); + + it('includes rawHtml in responseText when sentinel has rawHtml', async () => { + const rawHtml = ''; + const sentinel = { + count: 0, + events: [], + rawHtml, + error: 'Riksdagens kalender-API returnerade HTML.', + }; + const config = { + mcpUrl: 'https://mcp.test/mcp', + timeout: 3_000, + fetchFn: jsonFetch({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ text: JSON.stringify(sentinel) }] }, + }), + }; + + await expect( + callMcpCalendarEvents('2026-04-28', '2026-05-04', config), + ).rejects.toMatchObject({ kind: 'html', responseText: rawHtml }); + }); + + it('includes the error string in the thrown message for degraded sentinel', async () => { + const errorMsg = 'Riksdagens kalender-API returnerade HTML istället för JSON.'; + const sentinel = { count: 0, events: [], error: errorMsg }; + const config = { + mcpUrl: 'https://mcp.test/mcp', + timeout: 3_000, + fetchFn: jsonFetch({ + jsonrpc: '2.0', + id: 1, + result: { content: [{ text: JSON.stringify(sentinel) }] }, + }), + }; + + await expect( + callMcpCalendarEvents('2026-04-28', '2026-05-04', config), + ).rejects.toThrow(errorMsg); + }); + + it('throws CalendarMcpError(html) on a rawHtml-only sentinel (no error field)', async () => { + const config = { + mcpUrl: 'https://mcp.test/mcp', + timeout: 3_000, + fetchFn: jsonFetch({ + jsonrpc: '2.0', + id: 1, + result: { + count: 0, + events: [], + rawHtml: '503 Service Unavailable', + }, + }), + }; + + await expect( + callMcpCalendarEvents('2026-04-28', '2026-05-04', config), + ).rejects.toMatchObject({ kind: 'html' }); + }); }); diff --git a/tests/fetch-calendar/mcp/errors.test.ts b/tests/fetch-calendar/mcp/errors.test.ts index 6fae1bd082b..9a34ce30b48 100644 --- a/tests/fetch-calendar/mcp/errors.test.ts +++ b/tests/fetch-calendar/mcp/errors.test.ts @@ -101,6 +101,25 @@ describe('isDegradedKalenderSentinel', () => { ).toBe(true); }); + it('returns true when both error and rawHtml are present', () => { + expect( + isDegradedKalenderSentinel({ + count: 0, + events: [], + error: 'API degraded', + rawHtml: '', + notice: 'API:et fungerar inte korrekt för närvarande.', + suggestions: ['Försök igen senare'], + }), + ).toBe(true); + }); + + it('returns true for rawHtml-only sentinel without error field', () => { + expect( + isDegradedKalenderSentinel({ count: 0, events: [], rawHtml: '503' }), + ).toBe(true); + }); + it('returns false for a legitimate empty calendar window', () => { expect(isDegradedKalenderSentinel({ count: 0, events: [] })).toBe(false); expect(isDegradedKalenderSentinel({ kalender: [] })).toBe(false); @@ -114,6 +133,13 @@ describe('isDegradedKalenderSentinel', () => { it('returns false when error/rawHtml are non-string types', () => { expect(isDegradedKalenderSentinel({ events: [], error: null })).toBe(false); expect(isDegradedKalenderSentinel({ events: [], rawHtml: 0 })).toBe(false); + expect(isDegradedKalenderSentinel({ events: [], error: undefined })).toBe(false); + expect(isDegradedKalenderSentinel({ events: [], rawHtml: true })).toBe(false); + expect(isDegradedKalenderSentinel({ events: [], error: [] })).toBe(false); + }); + + it('returns false for an empty object', () => { + expect(isDegradedKalenderSentinel({})).toBe(false); }); }); diff --git a/tests/mcp-client/methods/calendar.test.ts b/tests/mcp-client/methods/calendar.test.ts index ebcf7745056..a535cd8414b 100644 --- a/tests/mcp-client/methods/calendar.test.ts +++ b/tests/mcp-client/methods/calendar.test.ts @@ -110,4 +110,49 @@ describe('methods/calendar — fetchCalendarEvents', () => { client.fetchCalendarEvents('2026-02-10', '2026-02-17'), ).rejects.toThrow(/degraded/i); }); + + it('should throw when the server returns a rawHtml-only sentinel (no error field)', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + jsonrpc: '2.0', + id: 1, + result: { + count: 0, + events: [], + rawHtml: '503', + }, + }), + }), + ) as unknown as typeof global.fetch; + + await expect( + client.fetchCalendarEvents('2026-02-10', '2026-02-17'), + ).rejects.toThrow(/degraded/i); + }); + + it('should include the upstream error message in the thrown error', async () => { + const errorMsg = 'Riksdagens kalender-API returnerade HTML istället för JSON.'; + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + jsonrpc: '2.0', + id: 1, + result: { + count: 0, + events: [], + error: errorMsg, + }, + }), + }), + ) as unknown as typeof global.fetch; + + await expect( + client.fetchCalendarEvents('2026-02-10', '2026-02-17'), + ).rejects.toThrow(errorMsg); + }); });