File size: 3,973 Bytes
46252cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
/**
 * Test helpers — a mock fetch transport that records calls and returns
 * canned responses, so tests assert on exact method/url/body/headers without
 * any real network or global monkey-patching.
 */
import type { FetchLike } from '../src';

export interface RecordedCall {
  url: string;
  method: string;
  headers: Record<string, string>;
  body: unknown;
}

export interface MockResponseSpec {
  status?: number;
  body?: unknown;
  text?: string;
}

/** A scripted mock transport. Throws if a call doesn't match a route. */
export class MockTransport {
  readonly calls: RecordedCall[] = [];
  private readonly routes: { method: string; matcher: RegExp; respond: (call: RecordedCall) => MockResponseSpec }[] =
    [];
  private fallback: ((call: RecordedCall) => MockResponseSpec) | null = null;

  /** Register a response for the first call matching `method` + `matcher`. */
  on(
    method: string,
    matcher: string | RegExp,
    respond: MockResponseSpec | ((call: RecordedCall) => MockResponseSpec),
  ): this {
    const regex = typeof matcher === 'string' ? new RegExp(matcher) : matcher;
    this.routes.push({
      method,
      matcher: regex,
      respond: typeof respond === 'function' ? respond : () => respond,
    });
    return this;
  }

  /** Catch-all for any unmatched call. */
  passthrough(respond: MockResponseSpec | ((call: RecordedCall) => MockResponseSpec)): this {
    this.fallback = typeof respond === 'function' ? respond : () => respond;
    return this;
  }

  /** @internal build the FetchLike impl consumed by the client. */
  asFetch(): FetchLike {
    return (async (input: RequestInfo | URL, init?: RequestInit) => {
      const fullUrl = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
      const method = (init?.method ?? 'GET').toUpperCase();
      const rawHeaders = init?.headers;
      const headers: Record<string, string> = {};
      if (rawHeaders) {
        const collect = (k: string, v: string): void => {
          headers[String(k).toLowerCase()] = String(v);
        };
        if (rawHeaders instanceof Headers) {
          rawHeaders.forEach((v, k) => collect(k, v));
        } else if (Array.isArray(rawHeaders)) {
          for (const [k, v] of rawHeaders) collect(k, String(v));
        } else {
          for (const [k, v] of Object.entries(rawHeaders as Record<string, string>)) collect(k, v);
        }
      }
      let body: unknown = undefined;
      if (init?.body) {
        try {
          body = JSON.parse(String(init.body));
        } catch {
          body = String(init.body);
        }
      }
      const call: RecordedCall = { url: fullUrl, method, headers, body };
      this.calls.push(call);

      // Route matching uses the pathname only (query stripped) so regexes
      // like `/contacts$` still match `…/contacts?limit=10`.
      const matchUrl = fullUrl.split('?')[0];

      let spec: MockResponseSpec;
      const route = this.routes.find(r => r.method === method && r.matcher.test(matchUrl));
      if (route) {
        spec = route.respond(call);
      } else if (this.fallback) {
        spec = this.fallback(call);
      } else {
        throw new Error(`MockTransport: no route for ${method} ${fullUrl}`);
      }

      const status = spec.status ?? 200;
      // 204/100/304 may not carry a body. Node's undici Response rejects
      // an empty-string body with these statuses, so pass `null`.
      const noBody = status === 204 || status === 100 || status === 304;
      const responseBody: BodyInit | null = noBody
        ? null
        : (spec.text ?? (spec.body === undefined ? '' : JSON.stringify(spec.body)));
      const resHeaders: Record<string, string> = noBody ? {} : { 'content-type': 'application/json' };
      return new Response(responseBody, { status, headers: resHeaders });
    }) as FetchLike;
  }

  get lastCall(): RecordedCall | undefined {
    return this.calls[this.calls.length - 1];
  }
}