http.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. import { describe, expect, test } from "bun:test"
  2. import { Effect, Exit } from "effect"
  3. import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
  4. import { existsSync } from "node:fs"
  5. import { isHttpInteraction } from "../src/cassette/model"
  6. import { HttpRecorder } from "../src"
  7. import { failureText, post, readCassette, seedCassetteDirectory, tempDirectory, withEnvironment } from "./support"
  8. const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
  9. Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch("http/multi-step"))))
  10. const runWith = <A, E>(
  11. name: string,
  12. options: HttpRecorder.RecorderOptions,
  13. effect: Effect.Effect<A, E, HttpClient.HttpClient>,
  14. ) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.layerFetch(name, options))))
  15. describe("HTTP", () => {
  16. test("decorates a provided HTTP client", async () => {
  17. await Effect.runPromise(
  18. Effect.all([post("https://example.test/echo", { step: 1 }), post("https://example.test/echo", { step: 2 })]).pipe(
  19. Effect.provide(HttpRecorder.layer("http/multi-step")),
  20. Effect.provide(FetchHttpClient.layer),
  21. ),
  22. )
  23. })
  24. test("replay returns recorded responses in order for identical requests", async () => {
  25. await runWith(
  26. "http/retry",
  27. {},
  28. Effect.gen(function* () {
  29. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
  30. expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
  31. }),
  32. )
  33. })
  34. test("replay reports exhaustion when more requests are made than recorded", async () => {
  35. await run(
  36. Effect.gen(function* () {
  37. yield* post("https://example.test/echo", { step: 1 })
  38. yield* post("https://example.test/echo", { step: 2 })
  39. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  40. expect(Exit.isFailure(exit)).toBe(true)
  41. }),
  42. )
  43. })
  44. test("a mismatch does not consume an interaction", async () => {
  45. await run(
  46. Effect.gen(function* () {
  47. yield* post("https://example.test/echo", { step: 1 })
  48. const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
  49. expect(Exit.isFailure(exit)).toBe(true)
  50. expect(failureText(exit)).toContain("$.step expected 2, received 3")
  51. expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
  52. }),
  53. )
  54. })
  55. test("distinct requests replay in any order", async () => {
  56. await run(
  57. Effect.gen(function* () {
  58. expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
  59. expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
  60. }),
  61. )
  62. })
  63. test("concurrent distinct requests atomically claim their matching interactions", async () => {
  64. const results = await run(
  65. Effect.all([post("https://example.test/echo", { step: 2 }), post("https://example.test/echo", { step: 1 })], {
  66. concurrency: "unbounded",
  67. }),
  68. )
  69. expect(results).toEqual(['{"reply":"second"}', '{"reply":"first"}'])
  70. })
  71. test("concurrent replay claims each interaction once", async () => {
  72. const results = await runWith(
  73. "http/retry",
  74. {},
  75. Effect.all(
  76. [post("https://example.test/poll", { id: "job_1" }), post("https://example.test/poll", { id: "job_1" })],
  77. { concurrency: "unbounded" },
  78. ),
  79. )
  80. expect(results.toSorted()).toEqual(['{"status":"complete"}', '{"status":"pending"}'])
  81. })
  82. test("mismatch diagnostics show redacted request differences against the expected interaction", async () => {
  83. await run(
  84. Effect.gen(function* () {
  85. const exit = yield* Effect.exit(
  86. post("https://example.test/echo?api_key=secret-value", {
  87. step: 3,
  88. token: "sk-123456789012345678901234",
  89. }),
  90. )
  91. const message = failureText(exit)
  92. expect(message).toContain("url:")
  93. expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
  94. expect(message).toContain("body:")
  95. expect(message).toContain("$.step expected 1, received 3")
  96. expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
  97. expect(message).not.toContain("sk-123456789012345678901234")
  98. }),
  99. )
  100. })
  101. test("applies custom URL redaction to mismatch errors", async () => {
  102. const secret = "private-account"
  103. const exit = await Effect.runPromiseExit(
  104. post(`https://example.test/${secret}`, { step: 1 }).pipe(
  105. Effect.provide(
  106. HttpRecorder.layerFetch("http/multi-step", {
  107. redact: { url: (url) => url.replace(secret, "{account}") },
  108. }),
  109. ),
  110. ),
  111. )
  112. const message = failureText(exit)
  113. expect(message).toContain("https://example.test/{account}")
  114. expect(message).not.toContain(secret)
  115. })
  116. test("fails when a non-empty replay cassette is completely unused", async () => {
  117. const exit = await Effect.runPromiseExit(
  118. Effect.void.pipe(Effect.scoped, Effect.provide(HttpRecorder.layerFetch("http/multi-step"))),
  119. )
  120. expect(Exit.isFailure(exit)).toBe(true)
  121. expect(failureText(exit)).toContain("Unused recorded interactions in http/multi-step: used 0 of 2")
  122. })
  123. test("allows an unused replay layer when the cassette is missing", async () => {
  124. using directory = tempDirectory("http-recorder-unused-missing-")
  125. await withEnvironment("CI", "true", () =>
  126. Effect.runPromise(
  127. Effect.void.pipe(
  128. Effect.scoped,
  129. Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })),
  130. ),
  131. ),
  132. )
  133. })
  134. describe("auto mode", () => {
  135. test("replays when the cassette exists", async () => {
  136. using directory = tempDirectory("http-recorder-auto-")
  137. await seedCassetteDirectory(directory.path, "auto-replay", [
  138. {
  139. transport: "http",
  140. request: {
  141. method: "POST",
  142. url: "https://example.test/echo",
  143. headers: { "content-type": "application/json" },
  144. body: JSON.stringify({ step: 1 }),
  145. },
  146. response: {
  147. status: 200,
  148. headers: { "content-type": "application/json" },
  149. body: '{"reply":"hi"}',
  150. },
  151. },
  152. ])
  153. const result = await runWith(
  154. "auto-replay",
  155. { directory: directory.path },
  156. post("https://example.test/echo", { step: 1 }),
  157. )
  158. expect(result).toBe('{"reply":"hi"}')
  159. })
  160. test("forces replay when CI=true even if cassette is missing", async () => {
  161. using directory = tempDirectory("http-recorder-auto-ci-")
  162. await withEnvironment("CI", "true", async () => {
  163. const exit = await Effect.runPromise(
  164. Effect.exit(
  165. post("https://example.test/echo", { step: 1 }).pipe(
  166. Effect.provide(HttpRecorder.layerFetch("missing-cassette", { directory: directory.path })),
  167. ),
  168. ),
  169. )
  170. expect(Exit.isFailure(exit)).toBe(true)
  171. expect(failureText(exit)).toContain('Fixture "missing-cassette" not found')
  172. })
  173. })
  174. test("records to disk when the cassette is missing", async () => {
  175. using directory = tempDirectory("http-recorder-auto-record-")
  176. using server = Bun.serve({
  177. port: 0,
  178. fetch: () =>
  179. new Response('{"reply":"recorded"}', {
  180. headers: { "content-type": "application/json" },
  181. }),
  182. })
  183. const url = `http://127.0.0.1:${server.port}/echo`
  184. await withEnvironment("CI", undefined, async () => {
  185. const result = await runWith("auto-record", { directory: directory.path }, post(url, { step: 1 }))
  186. expect(result).toBe('{"reply":"recorded"}')
  187. expect(existsSync(`${directory.path}/auto-record.json`)).toBe(true)
  188. })
  189. })
  190. test("records concurrent requests in request-start order", async () => {
  191. using directory = tempDirectory("http-recorder-order-")
  192. const first = Promise.withResolvers<void>()
  193. const completed: string[] = []
  194. using server = Bun.serve({
  195. port: 0,
  196. fetch: async (request) => {
  197. const name = new URL(request.url).pathname.slice(1)
  198. if (name === "first") {
  199. await first.promise
  200. completed.push(name)
  201. return new Response(name)
  202. }
  203. completed.push(name)
  204. first.resolve()
  205. return new Response(name)
  206. },
  207. })
  208. await withEnvironment("CI", undefined, async () => {
  209. const request = (name: string) =>
  210. Effect.gen(function* () {
  211. const http = yield* HttpClient.HttpClient
  212. const response = yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/${name}`))
  213. return yield* response.text
  214. })
  215. const responses = await Effect.runPromise(
  216. Effect.all([request("first"), request("second")], {
  217. concurrency: "unbounded",
  218. }).pipe(Effect.provide(HttpRecorder.layerFetch("concurrent-order", { directory: directory.path }))),
  219. )
  220. const cassette = readCassette(`${directory.path}/concurrent-order.json`)
  221. expect(completed).toEqual(["second", "first"])
  222. expect(responses).toEqual(["first", "second"])
  223. expect(cassette.interactions.filter(isHttpInteraction).map((interaction) => interaction.request.url)).toEqual([
  224. `http://127.0.0.1:${server.port}/first`,
  225. `http://127.0.0.1:${server.port}/second`,
  226. ])
  227. })
  228. })
  229. test("returns the live response while persisting its redacted snapshot", async () => {
  230. using directory = tempDirectory("http-recorder-live-response-")
  231. using server = Bun.serve({
  232. port: 0,
  233. fetch: () =>
  234. new Response(JSON.stringify({ access_token: "live-secret", safe: true }), {
  235. headers: {
  236. "content-type": "application/json",
  237. "x-request-id": "request-1",
  238. },
  239. }),
  240. })
  241. await withEnvironment("CI", undefined, async () => {
  242. const body = await runWith(
  243. "live-response",
  244. { directory: directory.path },
  245. post(`http://127.0.0.1:${server.port}/response`, { ok: true }),
  246. )
  247. const cassette = readCassette(`${directory.path}/live-response.json`)
  248. const interaction = cassette.interactions.find(isHttpInteraction)
  249. expect(body).toBe('{"access_token":"live-secret","safe":true}')
  250. expect(interaction?.response.body).toBe('{"access_token":"[REDACTED]","safe":true}')
  251. })
  252. })
  253. test("reconstructs responses with null-body statuses", async () => {
  254. using directory = tempDirectory("http-recorder-no-content-")
  255. using server = Bun.serve({
  256. port: 0,
  257. fetch: () => new Response(null, { status: 204 }),
  258. })
  259. await withEnvironment("CI", undefined, async () => {
  260. const program = Effect.gen(function* () {
  261. const http = yield* HttpClient.HttpClient
  262. return yield* http.execute(HttpClientRequest.get(`http://127.0.0.1:${server.port}/empty`))
  263. })
  264. const response = await Effect.runPromise(
  265. program.pipe(Effect.provide(HttpRecorder.layerFetch("no-content", { directory: directory.path }))),
  266. )
  267. expect(response.status).toBe(204)
  268. })
  269. })
  270. test("records and replays arbitrary binary responses without changing bytes", async () => {
  271. using directory = tempDirectory("http-recorder-binary-")
  272. const expected = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0xff, 0x00, 0x80])
  273. using server = Bun.serve({
  274. port: 0,
  275. fetch: () => new Response(expected, { headers: { "content-type": "image/png" } }),
  276. })
  277. const url = `http://127.0.0.1:${server.port}/image.png`
  278. await withEnvironment("CI", undefined, async () => {
  279. const program = Effect.gen(function* () {
  280. const http = yield* HttpClient.HttpClient
  281. const response = yield* http.execute(HttpClientRequest.get(url))
  282. return new Uint8Array(yield* response.arrayBuffer)
  283. })
  284. const record = await Effect.runPromise(
  285. program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))),
  286. )
  287. await server.stop()
  288. const replay = await Effect.runPromise(
  289. program.pipe(Effect.provide(HttpRecorder.layerFetch("binary", { directory: directory.path }))),
  290. )
  291. const cassette = readCassette(`${directory.path}/binary.json`)
  292. const interaction = cassette.interactions.find(isHttpInteraction)
  293. expect(record).toEqual(expected)
  294. expect(replay).toEqual(expected)
  295. expect(interaction?.response.bodyEncoding).toBe("base64")
  296. })
  297. })
  298. })
  299. })