All files / scripts/fetch-calendar orchestrator.ts

94.44% Statements 34/36
64% Branches 16/25
33.33% Functions 1/3
96.96% Lines 32/33

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128                                                                                          8x 8x 8x 8x 8x 8x 8x   8x     8x 11x 3x 3x 3x     11x 11x     11x 4x 4x   4x                     7x 7x 7x 7x     7x       4x   4x 4x 3x   3x                       1x 1x     1x                          
/**
 * @module scripts/fetch-calendar/orchestrator
 * @description Primary→fallback orchestrator for the Riksdag calendar fetch.
 *
 * 1. **MCP primary**: call `get_calendar_events` on riksdag-regering.
 *    Retries up to `maxRetries` times on transient failures (network/json/tool).
 *    Breaks early on `html` kind — when the endpoint serves an HTML error
 *    page there is no point retrying.
 * 2. **Web fallback**: scrape `riksdagen.se/sv/kalendarium/` instead.
 *
 * Decision lives in **one** function ≤ 90 lines, per the refactor brief.
 *
 * @author Hack23 AB
 * @license Apache-2.0
 */
 
import {
  callMcpCalendarEvents,
  DEFAULT_MAX_RETRIES,
  DEFAULT_MCP_URL,
  DEFAULT_TIMEOUT,
  RETRY_BASE_DELAY_MS,
} from './mcp/client.js';
import { CalendarMcpError } from './mcp/errors.js';
import { normalizeMcpCalendarEvent } from './mcp/normaliser.js';
import { DEFAULT_WEB_BASE_URL, fetchWebCalendar } from './scraper/parse.js';
import type { CalendarFetchConfig, CalendarFetchResult } from './types.js';
 
function defaultSleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
 
/**
 * Fetch Riksdag calendar events for the given date range using a
 * primary→fallback resilience chain.
 *
 * @param from  ISO 8601 date string (inclusive start, e.g. "2026-04-28").
 * @param to    ISO 8601 date string (inclusive end,   e.g. "2026-05-04").
 * @param config Optional overrides for URLs, timeout, retries, and fetch mock.
 */
export async function fetchCalendarWithFallback(
  from: string,
  to: string,
  config: CalendarFetchConfig = {},
): Promise<CalendarFetchResult> {
  const mcpUrl = config.mcpUrl ?? DEFAULT_MCP_URL;
  const webBaseUrl = config.webBaseUrl ?? DEFAULT_WEB_BASE_URL;
  const timeout = config.timeout ?? DEFAULT_TIMEOUT;
  const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
  const fetchFn = config.fetchFn ?? globalThis.fetch;
  const sleepFn = config.sleepFn ?? defaultSleep;
  const fetchedAt = new Date().toISOString();
 
  const resolved = { mcpUrl, webBaseUrl, timeout, fetchFn, sleepFn };
 
  let primaryError: string | undefined;
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    if (attempt > 0) {
      const delay = Math.min(RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1), 30_000);
      console.warn(`  ⚠️  MCP calendar retry ${attempt}/${maxRetries} after ${delay} ms…`);
      await sleepFn(delay);
    }
 
    try {
      console.error(
        `  🔄 [fetch-calendar] MCP primary attempt ${attempt + 1}/${maxRetries + 1}…`,
      );
      const raw = await callMcpCalendarEvents(from, to, resolved);
      const events = raw.map(normalizeMcpCalendarEvent);
      console.error(`  ✅ [fetch-calendar] MCP primary succeeded — ${events.length} events`);
 
      return {
        events,
        manifest: {
          date: from,
          dateTo: to,
          path: 'mcp-primary',
          eventCount: events.length,
          fetchedAt,
        },
      };
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      primaryError = msg;
      const kind = err instanceof CalendarMcpError ? err.kind : 'unknown';
      console.warn(
        `  ⚠️  [fetch-calendar] MCP attempt ${attempt + 1} failed (${kind}): ${msg.slice(0, 120)}`,
      );
      if (err instanceof CalendarMcpError && err.kind === 'html') break;
    }
  }
 
  console.error(`  🔄 [fetch-calendar] Falling back to riksdagen.se/sv/kalendarium/…`);
  let fallbackError: string | undefined;
  try {
    const events = await fetchWebCalendar(from, to, resolved);
    console.error(`  ✅ [fetch-calendar] Web fallback succeeded — ${events.length} events`);
 
    return {
      events,
      manifest: {
        date: from,
        dateTo: to,
        path: 'web-fallback',
        eventCount: events.length,
        primaryError,
        fetchedAt,
      },
    };
  } catch (err) {
    fallbackError = err instanceof Error ? err.message : String(err);
    console.error(`  ❌ [fetch-calendar] Web fallback also failed: ${fallbackError}`);
  }
 
  return {
    events: [],
    manifest: {
      date: from,
      dateTo: to,
      path: 'none',
      eventCount: 0,
      primaryError,
      fallbackError,
      fetchedAt,
    },
  };
}