WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
3const mockFetch = vi.fn();
4
5describe("fetchApi", () => {
6 beforeEach(() => {
7 vi.stubGlobal("fetch", mockFetch);
8 vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
9 vi.resetModules();
10 });
11
12 afterEach(() => {
13 vi.unstubAllGlobals();
14 vi.unstubAllEnvs();
15 mockFetch.mockReset();
16 });
17
18 async function loadFetchApi() {
19 const mod = await import("../api.js");
20 return mod.fetchApi;
21 }
22
23 it("calls the correct URL", async () => {
24 mockFetch.mockResolvedValueOnce({
25 ok: true,
26 json: () => Promise.resolve({ data: "test" }),
27 });
28
29 const fetchApi = await loadFetchApi();
30 await fetchApi("/categories");
31
32 expect(mockFetch).toHaveBeenCalledOnce();
33 const calledUrl = mockFetch.mock.calls[0][0];
34 expect(calledUrl).toBe("http://localhost:3000/api/categories");
35 });
36
37 it("returns parsed JSON on success", async () => {
38 const expected = { categories: [{ id: 1, name: "General" }] };
39 mockFetch.mockResolvedValueOnce({
40 ok: true,
41 json: () => Promise.resolve(expected),
42 });
43
44 const fetchApi = await loadFetchApi();
45 const result = await fetchApi("/categories");
46 expect(result).toEqual(expected);
47 });
48
49 it("throws on non-ok response", async () => {
50 mockFetch.mockResolvedValueOnce({
51 ok: false,
52 status: 500,
53 statusText: "Internal Server Error",
54 });
55
56 const fetchApi = await loadFetchApi();
57 await expect(fetchApi("/fail")).rejects.toThrow(
58 "AppView API error: 500 Internal Server Error"
59 );
60 });
61
62 it("throws on 404 response", async () => {
63 mockFetch.mockResolvedValueOnce({
64 ok: false,
65 status: 404,
66 statusText: "Not Found",
67 });
68
69 const fetchApi = await loadFetchApi();
70 await expect(fetchApi("/missing")).rejects.toThrow(
71 "AppView API error: 404 Not Found"
72 );
73 });
74
75 it("forwards cookieHeader as Cookie header when provided", async () => {
76 mockFetch.mockResolvedValueOnce({
77 ok: true,
78 json: () => Promise.resolve({}),
79 });
80
81 const fetchApi = await loadFetchApi();
82 await fetchApi("/boards", { cookieHeader: "atbb_session=mytoken" });
83
84 const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
85 expect((init.headers as Record<string, string>)["Cookie"]).toBe(
86 "atbb_session=mytoken"
87 );
88 });
89
90 it("does not set Cookie header when cookieHeader is not provided", async () => {
91 mockFetch.mockResolvedValueOnce({
92 ok: true,
93 json: () => Promise.resolve({}),
94 });
95
96 const fetchApi = await loadFetchApi();
97 await fetchApi("/boards");
98
99 const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
100 expect((init.headers as Record<string, string>)["Cookie"]).toBeUndefined();
101 });
102
103 it("throws a network error with descriptive message when AppView is unreachable", async () => {
104 mockFetch.mockRejectedValueOnce(new Error("fetch failed: ECONNREFUSED"));
105
106 const fetchApi = await loadFetchApi();
107 await expect(fetchApi("/boards")).rejects.toThrow(
108 "AppView network error: fetch failed: ECONNREFUSED"
109 );
110 });
111
112 it("throws a response error when AppView returns malformed JSON", async () => {
113 mockFetch.mockResolvedValueOnce({
114 ok: true,
115 json: () => Promise.reject(new SyntaxError("Unexpected token '<'")),
116 });
117
118 const fetchApi = await loadFetchApi();
119 await expect(fetchApi("/boards")).rejects.toThrow(
120 "AppView response error: invalid JSON from /boards"
121 );
122 });
123});