File size: 5,445 Bytes
9de795a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
129
130
131
132
133
134
135
136
137
/** Weck-Verhalten des API-Clients (s. backend.ts): fetch ist gemockt, die Zeit
 *  simuliert – ein «3-Minuten-Aufwachen» läuft in Millisekunden. */
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MAX_WECKDAUER_MS, type BackendStatus } from "./backend";

const json = (body: unknown, status = 200) =>
  new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } });
const html = (status = 200) =>
  new Response("<html>Space startet…</html>", {
    status,
    headers: { "content-type": "text/html" },
  });

type Antwort = Response | Error;

/** fetch-Mock: je URL eine Schlange von Antworten; die letzte wiederholt sich. */
function mockFetch(schlangen: Record<string, (() => Antwort)[]>) {
  const aufrufe: string[] = [];
  const fn = vi.fn((url: string) => {
    aufrufe.push(url);
    const schlange = schlangen[url];
    if (!schlange || schlange.length === 0) throw new Error(`kein Mock für ${url}`);
    const naechste = (schlange.length > 1 ? schlange.shift() : schlange[0]) as () => Antwort;
    const a = naechste();
    return a instanceof Error ? Promise.reject(a) : Promise.resolve(a);
  });
  vi.stubGlobal("fetch", fn);
  return aufrufe;
}

async function ladeApi() {
  // Frisches Modul je Test: Weck-Zustand und Beobachter sind Modul-Zustand.
  vi.resetModules();
  const mod = await import("./api");
  const ereignisse: BackendStatus[] = [];
  mod.beobachteBackend((s) => ereignisse.push(s));
  return { ...mod, ereignisse };
}

beforeEach(() => {
  vi.useFakeTimers();
});

afterEach(() => {
  vi.useRealTimers();
  vi.unstubAllGlobals();
});

describe("api – Backend wecken", () => {
  it("normale JSON-Antwort: kein Wecken, genau ein Aufruf", async () => {
    const aufrufe = mockFetch({ "/api/samples/rooms": [() => json([{ id: "r1" }])] });
    const { api, ereignisse } = await ladeApi();
    await expect(api.rooms()).resolves.toEqual([{ id: "r1" }]);
    expect(aufrufe).toEqual(["/api/samples/rooms"]);
    expect(ereignisse).toEqual([]);
  });

  it("HTML-Ladeseite (Status 200): weckt, wiederholt EINMAL und liefert das Ergebnis", async () => {
    const aufrufe = mockFetch({
      "/api/samples/rooms": [() => html(), () => json([{ id: "r1" }])],
      "/api/health": [() => html(), () => json({ status: "ok" })],
    });
    const { api, ereignisse } = await ladeApi();
    const antwort = api.rooms();
    await vi.advanceTimersByTimeAsync(10_000);
    await expect(antwort).resolves.toEqual([{ id: "r1" }]);
    expect(ereignisse).toEqual(["wacht", "bereit"]);
    expect(aufrufe.filter((u) => u === "/api/samples/rooms")).toHaveLength(2);
  });

  it("JSON-Fehler-Envelope ist eine echte Antwort: Fehler durchreichen, nicht wecken", async () => {
    const aufrufe = mockFetch({
      "/api/samples/rooms": [() => json({ code: "NO_FEASIBLE_PLACEMENT", message: "zu eng" }, 422)],
    });
    const { api, ereignisse } = await ladeApi();
    await expect(api.rooms()).rejects.toMatchObject({
      code: "NO_FEASIBLE_PLACEMENT",
      message: "zu eng",
    });
    expect(aufrufe).toHaveLength(1);
    expect(ereignisse).toEqual([]);
  });

  it("Server bleibt weg: verständlicher Fehler statt SyntaxError", async () => {
    mockFetch({ "/api/samples/rooms": [() => html()], "/api/health": [() => html()] });
    const { api, ereignisse, NICHT_ERREICHBAR } = await ladeApi();
    const antwort = api.rooms();
    const erwartung = expect(antwort).rejects.toMatchObject({ code: NICHT_ERREICHBAR });
    await vi.advanceTimersByTimeAsync(MAX_WECKDAUER_MS + 10_000);
    await erwartung;
    expect(ereignisse).toEqual(["wacht", "nicht-erreichbar"]);
  });

  it("gleichzeitige Aufrufe teilen EINEN Weckvorgang", async () => {
    const aufrufe = mockFetch({
      "/api/samples/rooms": [() => html(), () => json([])],
      "/api/taxonomy": [() => html(), () => json({ achsen: [] })],
      "/api/health": [() => html(), () => json({ status: "ok" })],
    });
    const { api, ereignisse } = await ladeApi();
    const beide = Promise.all([api.rooms(), api.taxonomy()]);
    await vi.advanceTimersByTimeAsync(10_000);
    await expect(beide).resolves.toEqual([[], { achsen: [] }]);
    // Ein Weckvorgang = zwei Pings (html, dann ok) – nicht vier.
    expect(aufrufe.filter((u) => u === "/api/health")).toHaveLength(2);
    expect(ereignisse).toEqual(["wacht", "bereit"]);
  });

  it("Netzfehler wird wie ein schlafendes Backend behandelt", async () => {
    mockFetch({
      "/api/samples/rooms": [() => new TypeError("Failed to fetch"), () => json([{ id: "r1" }])],
      "/api/health": [() => json({ status: "ok" })],
    });
    const { api } = await ladeApi();
    const antwort = api.rooms();
    await vi.advanceTimersByTimeAsync(1_000);
    await expect(antwort).resolves.toEqual([{ id: "r1" }]);
  });

  it("wach, aber weiterhin keine JSON-Antwort: Fehler nennt den HTTP-Status", async () => {
    mockFetch({
      "/api/samples/rooms": [
        () => new Response("zu gross", { status: 413, headers: { "content-type": "text/plain" } }),
      ],
      "/api/health": [() => json({ status: "ok" })],
    });
    const { api } = await ladeApi();
    const antwort = api.rooms();
    const erwartung = expect(antwort).rejects.toMatchObject({
      code: "UNERWARTETE_ANTWORT",
      message: expect.stringContaining("HTTP 413"),
    });
    await vi.advanceTimersByTimeAsync(1_000);
    await erwartung;
  });
});