|
| 1 | +import { describe, expect, it } from "vitest" |
| 2 | +import { createMisina } from "../src/index.ts" |
| 3 | + |
| 4 | +function recordingDriver() { |
| 5 | + const seen: { url: string; headers: Record<string, string> }[] = [] |
| 6 | + return { |
| 7 | + seen, |
| 8 | + driver: { |
| 9 | + name: "rec", |
| 10 | + request: async (req: Request) => { |
| 11 | + const headers: Record<string, string> = {} |
| 12 | + req.headers.forEach((v, k) => { |
| 13 | + headers[k] = v |
| 14 | + }) |
| 15 | + seen.push({ url: req.url, headers }) |
| 16 | + return new Response("{}", { headers: { "content-type": "application/json" } }) |
| 17 | + }, |
| 18 | + }, |
| 19 | + } |
| 20 | +} |
| 21 | + |
| 22 | +describe("baseURL resolution — WHATWG URL semantics", () => { |
| 23 | + it("baseURL without trailing slash: relative path resolves correctly", async () => { |
| 24 | + const { seen, driver } = recordingDriver() |
| 25 | + const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1" }) |
| 26 | + await m.get("users") |
| 27 | + expect(seen[0]?.url).toBe("https://api.test/v1/users") |
| 28 | + }) |
| 29 | + |
| 30 | + it("baseURL with trailing slash: relative path appended", async () => { |
| 31 | + const { seen, driver } = recordingDriver() |
| 32 | + const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1/" }) |
| 33 | + await m.get("users") |
| 34 | + expect(seen[0]?.url).toBe("https://api.test/v1/users") |
| 35 | + }) |
| 36 | + |
| 37 | + it("baseURL with path; absolute path in input replaces it", async () => { |
| 38 | + const { seen, driver } = recordingDriver() |
| 39 | + const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1" }) |
| 40 | + await m.get("/health") |
| 41 | + // Absolute path on the same origin — replaces baseURL's path entirely. |
| 42 | + expect(seen[0]?.url).toBe("https://api.test/health") |
| 43 | + }) |
| 44 | + |
| 45 | + it("absolute URL in input overrides baseURL", async () => { |
| 46 | + const { seen, driver } = recordingDriver() |
| 47 | + const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/" }) |
| 48 | + await m.get("https://other.test/external") |
| 49 | + expect(seen[0]?.url).toBe("https://other.test/external") |
| 50 | + }) |
| 51 | + |
| 52 | + it("allowAbsoluteUrls: false rejects an absolute URL even with baseURL set", async () => { |
| 53 | + const m = createMisina({ |
| 54 | + driver: recordingDriver().driver, |
| 55 | + retry: 0, |
| 56 | + baseURL: "https://api.test/", |
| 57 | + allowAbsoluteUrls: false, |
| 58 | + }) |
| 59 | + await expect(m.get("https://attacker.test/x")).rejects.toThrow(/rejected/) |
| 60 | + }) |
| 61 | + |
| 62 | + it("baseURL with deep path; relative does not eat segments", async () => { |
| 63 | + const { seen, driver } = recordingDriver() |
| 64 | + const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/v1/users/" }) |
| 65 | + await m.get("123/posts") |
| 66 | + expect(seen[0]?.url).toBe("https://api.test/v1/users/123/posts") |
| 67 | + }) |
| 68 | + |
| 69 | + it("query is appended to the resolved URL", async () => { |
| 70 | + const { seen, driver } = recordingDriver() |
| 71 | + const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/" }) |
| 72 | + await m.get("search", { query: { q: "fish", lang: "tr" } }) |
| 73 | + expect(seen[0]?.url).toBe("https://api.test/search?q=fish&lang=tr") |
| 74 | + }) |
| 75 | + |
| 76 | + it("baseURL with query string keeps that query and merges with init.query", async () => { |
| 77 | + const { seen, driver } = recordingDriver() |
| 78 | + // baseURL with a built-in query is unusual but legal. |
| 79 | + const m = createMisina({ driver, retry: 0, baseURL: "https://api.test/?token=abc" }) |
| 80 | + await m.get("items", { query: { page: "2" } }) |
| 81 | + // Whether the token survives depends on URL semantics. Verify we don't lose |
| 82 | + // the path/query unexpectedly. |
| 83 | + expect(seen[0]?.url.startsWith("https://api.test/items?")).toBe(true) |
| 84 | + expect(seen[0]?.url).toContain("page=2") |
| 85 | + }) |
| 86 | +}) |
| 87 | + |
| 88 | +describe("headers — input formats", () => { |
| 89 | + it("Record<string, string> works", async () => { |
| 90 | + const { seen, driver } = recordingDriver() |
| 91 | + const m = createMisina({ driver, retry: 0, headers: { "x-tag": "default" } }) |
| 92 | + await m.get("https://api.test/", { headers: { "x-trace": "abc" } }) |
| 93 | + expect(seen[0]?.headers["x-tag"]).toBe("default") |
| 94 | + expect(seen[0]?.headers["x-trace"]).toBe("abc") |
| 95 | + }) |
| 96 | + |
| 97 | + it("Headers instance works as defaults headers", async () => { |
| 98 | + const { seen, driver } = recordingDriver() |
| 99 | + const m = createMisina({ |
| 100 | + driver, |
| 101 | + retry: 0, |
| 102 | + headers: new Headers({ "x-tag": "default" }) as unknown as Record<string, string>, |
| 103 | + }) |
| 104 | + await m.get("https://api.test/") |
| 105 | + expect(seen[0]?.headers["x-tag"]).toBe("default") |
| 106 | + }) |
| 107 | + |
| 108 | + it("[string, string][] tuple-array works as headers", async () => { |
| 109 | + const { seen, driver } = recordingDriver() |
| 110 | + const m = createMisina({ |
| 111 | + driver, |
| 112 | + retry: 0, |
| 113 | + headers: [ |
| 114 | + ["x-tag", "default"], |
| 115 | + ["x-extra", "1"], |
| 116 | + ] as unknown as Record<string, string>, |
| 117 | + }) |
| 118 | + await m.get("https://api.test/") |
| 119 | + expect(seen[0]?.headers["x-tag"]).toBe("default") |
| 120 | + expect(seen[0]?.headers["x-extra"]).toBe("1") |
| 121 | + }) |
| 122 | + |
| 123 | + it("per-request headers override defaults case-insensitively", async () => { |
| 124 | + const { seen, driver } = recordingDriver() |
| 125 | + const m = createMisina({ |
| 126 | + driver, |
| 127 | + retry: 0, |
| 128 | + headers: { Accept: "application/json" }, |
| 129 | + }) |
| 130 | + await m.get("https://api.test/", { headers: { accept: "text/plain" } }) |
| 131 | + // Only one accept value, taken from the per-request side. |
| 132 | + expect(seen[0]?.headers["accept"]).toBe("text/plain") |
| 133 | + }) |
| 134 | + |
| 135 | + it("setting a header to undefined does not crash; header is absent", async () => { |
| 136 | + const { seen, driver } = recordingDriver() |
| 137 | + const m = createMisina({ driver, retry: 0 }) |
| 138 | + await m.get("https://api.test/", { |
| 139 | + headers: { "x-real": "1", "x-removed": undefined as unknown as string }, |
| 140 | + }) |
| 141 | + expect(seen[0]?.headers["x-real"]).toBe("1") |
| 142 | + expect(seen[0]?.headers["x-removed"]).toBeUndefined() |
| 143 | + }) |
| 144 | + |
| 145 | + it(".extend() merges defaults headers with parent headers", async () => { |
| 146 | + const { seen, driver } = recordingDriver() |
| 147 | + const parent = createMisina({ |
| 148 | + driver, |
| 149 | + retry: 0, |
| 150 | + headers: { "x-parent": "p", "x-shared": "parent" }, |
| 151 | + }) |
| 152 | + const child = parent.extend({ headers: { "x-child": "c", "x-shared": "child" } }) |
| 153 | + await child.get("https://api.test/") |
| 154 | + expect(seen[0]?.headers["x-parent"]).toBe("p") |
| 155 | + expect(seen[0]?.headers["x-child"]).toBe("c") |
| 156 | + expect(seen[0]?.headers["x-shared"]).toBe("child") |
| 157 | + }) |
| 158 | +}) |
| 159 | + |
| 160 | +describe("hooks — array merging across defaults + per-request + extend", () => { |
| 161 | + it("init hooks from defaults run before per-request init hooks", async () => { |
| 162 | + const { driver } = recordingDriver() |
| 163 | + const order: string[] = [] |
| 164 | + |
| 165 | + const m = createMisina({ |
| 166 | + driver, |
| 167 | + retry: 0, |
| 168 | + hooks: { init: [() => order.push("default-init")] }, |
| 169 | + }) |
| 170 | + |
| 171 | + await m.get("https://api.test/", { |
| 172 | + hooks: { init: [() => order.push("per-request-init")] }, |
| 173 | + }) |
| 174 | + |
| 175 | + expect(order).toEqual(["default-init", "per-request-init"]) |
| 176 | + }) |
| 177 | + |
| 178 | + it("beforeRequest hooks fire in defaults-then-per-request order", async () => { |
| 179 | + const { driver } = recordingDriver() |
| 180 | + const order: string[] = [] |
| 181 | + |
| 182 | + const m = createMisina({ |
| 183 | + driver, |
| 184 | + retry: 0, |
| 185 | + hooks: { |
| 186 | + beforeRequest: [ |
| 187 | + () => { |
| 188 | + order.push("default-before") |
| 189 | + }, |
| 190 | + ], |
| 191 | + }, |
| 192 | + }) |
| 193 | + |
| 194 | + await m.get("https://api.test/", { |
| 195 | + hooks: { |
| 196 | + beforeRequest: [ |
| 197 | + () => { |
| 198 | + order.push("per-request-before") |
| 199 | + }, |
| 200 | + ], |
| 201 | + }, |
| 202 | + }) |
| 203 | + |
| 204 | + expect(order).toEqual(["default-before", "per-request-before"]) |
| 205 | + }) |
| 206 | + |
| 207 | + it(".extend() concatenates hooks, doesn't replace", async () => { |
| 208 | + const { driver } = recordingDriver() |
| 209 | + const order: string[] = [] |
| 210 | + const parent = createMisina({ |
| 211 | + driver, |
| 212 | + retry: 0, |
| 213 | + hooks: { |
| 214 | + beforeRequest: [ |
| 215 | + () => { |
| 216 | + order.push("parent") |
| 217 | + }, |
| 218 | + ], |
| 219 | + }, |
| 220 | + }) |
| 221 | + const child = parent.extend({ |
| 222 | + hooks: { |
| 223 | + beforeRequest: [ |
| 224 | + () => { |
| 225 | + order.push("child") |
| 226 | + }, |
| 227 | + ], |
| 228 | + }, |
| 229 | + }) |
| 230 | + await child.get("https://api.test/") |
| 231 | + expect(order).toEqual(["parent", "child"]) |
| 232 | + }) |
| 233 | +}) |
0 commit comments