@distilled.cloud/stripe 1.0.0-rc.13 → 1.0.0-rc.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/lib/credentials.d.ts +2 -2
  2. package/lib/credentials.d.ts.map +1 -1
  3. package/lib/credentials.js +2 -2
  4. package/lib/credentials.js.map +1 -1
  5. package/lib/errors.d.ts +1 -1
  6. package/lib/errors.d.ts.map +1 -1
  7. package/lib/errors.js +1 -1
  8. package/lib/errors.js.map +1 -1
  9. package/lib/protocol.d.ts +3 -3
  10. package/lib/protocol.d.ts.map +1 -1
  11. package/lib/protocol.js +13 -18
  12. package/lib/protocol.js.map +1 -1
  13. package/lib/protocol.test.d.ts +2 -0
  14. package/lib/protocol.test.d.ts.map +1 -0
  15. package/lib/protocol.test.js +353 -0
  16. package/lib/protocol.test.js.map +1 -0
  17. package/lib/retry.d.ts +1 -1
  18. package/lib/retry.d.ts.map +1 -1
  19. package/lib/retry.js +1 -1
  20. package/lib/retry.js.map +1 -1
  21. package/lib/services/stripe.d.ts +4970 -1571
  22. package/lib/services/stripe.d.ts.map +1 -1
  23. package/lib/services/stripe.js +5608 -6855
  24. package/lib/services/stripe.js.map +1 -1
  25. package/lib/traits.d.ts.map +1 -1
  26. package/lib/traits.js +1 -1
  27. package/lib/traits.js.map +1 -1
  28. package/lib/webhooks.d.ts.map +1 -1
  29. package/lib/webhooks.js +4 -4
  30. package/lib/webhooks.js.map +1 -1
  31. package/package.json +12 -10
  32. package/src/credentials.ts +6 -7
  33. package/src/errors.ts +20 -29
  34. package/src/protocol.test.ts +519 -0
  35. package/src/protocol.ts +54 -104
  36. package/src/retry.ts +2 -8
  37. package/src/services/stripe.ts +40402 -47044
  38. package/src/traits.ts +1 -4
  39. package/src/webhooks.ts +11 -30
  40. package/lib/response-validation.test.d.ts +0 -4
  41. package/lib/response-validation.test.d.ts.map +0 -1
  42. package/lib/response-validation.test.js +0 -38
  43. package/lib/response-validation.test.js.map +0 -1
  44. package/src/response-validation.test.ts +0 -52
@@ -0,0 +1,519 @@
1
+ import * as ResponseValidation from "@distilled.cloud/core/response-validation";
2
+ import * as Duration from "effect/Duration";
3
+ import * as Effect from "effect/Effect";
4
+ import * as HttpClient from "effect/http/HttpClient";
5
+ import type * as HttpClientRequest from "effect/http/HttpClientRequest";
6
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
7
+ import * as Layer from "effect/Layer";
8
+ import * as Redacted from "effect/Redacted";
9
+ import { describe, expect, test } from "vitest";
10
+ import { credentials } from "./credentials.ts";
11
+ import {
12
+ ApiError,
13
+ CardError,
14
+ ExternalDependencyFailed,
15
+ IdempotencyError,
16
+ InternalServerError,
17
+ InvalidRequestError,
18
+ NotFound,
19
+ PaymentError,
20
+ StripeParseError,
21
+ TooManyRequests,
22
+ UnknownStripeError,
23
+ } from "./errors.ts";
24
+ import { type StripeOpContext, withRequestOptions } from "./protocol.ts";
25
+ import * as Retry from "./retry.ts";
26
+ import {
27
+ CreateBillingMeterEvent2,
28
+ CreateCustomer,
29
+ CreateFile,
30
+ DeleteProduct,
31
+ GetCustomer,
32
+ GetCustomers,
33
+ ProductHasPrices,
34
+ } from "./services/stripe.ts";
35
+
36
+ const BASE_URL = "https://stripe.test";
37
+ const API_KEY = "sk_test_123";
38
+
39
+ interface Recorded {
40
+ readonly method: string;
41
+ readonly url: URL;
42
+ readonly headers: Record<string, string>;
43
+ readonly body: HttpClientRequest.HttpClientRequest["body"];
44
+ }
45
+
46
+ const decoder = new TextDecoder();
47
+
48
+ const textOf = (request: Recorded): string | undefined =>
49
+ request.body._tag === "Uint8Array" ? decoder.decode(request.body.body) : undefined;
50
+
51
+ /** Decoded `key=value` pairs, in wire order. */
52
+ const formPairs = (request: Recorded): Array<[string, string]> => [
53
+ ...new URLSearchParams(textOf(request)).entries(),
54
+ ];
55
+
56
+ const run = <A, E>(
57
+ operation: Effect.Effect<A, E, StripeOpContext>,
58
+ respond: (request: Recorded) => Response = () => json({}),
59
+ ) => {
60
+ const requests: Recorded[] = [];
61
+ const client = HttpClient.make((request) =>
62
+ Effect.sync(() => {
63
+ const recorded: Recorded = {
64
+ method: request.method,
65
+ url: new URL(request.url),
66
+ headers: { ...request.headers },
67
+ body: request.body,
68
+ };
69
+ requests.push(recorded);
70
+ return HttpClientResponse.fromWeb(request, respond(recorded));
71
+ }),
72
+ );
73
+ const result = Effect.runPromise(
74
+ operation.pipe(
75
+ Retry.none,
76
+ Effect.provide(
77
+ Layer.mergeAll(
78
+ Layer.succeed(HttpClient.HttpClient, client),
79
+ credentials({ apiKey: Redacted.make(API_KEY), apiBaseUrl: BASE_URL }),
80
+ ),
81
+ ),
82
+ ),
83
+ );
84
+ return { result, requests };
85
+ };
86
+
87
+ const runError = <A, E>(
88
+ operation: Effect.Effect<A, E, StripeOpContext>,
89
+ respond: (request: Recorded) => Response,
90
+ ) => run(Effect.flip(operation), respond).result;
91
+
92
+ const json = (value: unknown, status = 200, headers: Record<string, string> = {}) =>
93
+ new Response(JSON.stringify(value), {
94
+ status,
95
+ headers: { "Content-Type": "application/json", ...headers },
96
+ });
97
+
98
+ describe("Form encoding (POST /v1)", () => {
99
+ test("nested objects, indexed arrays, booleans, numbers and metadata use bracket notation", async () => {
100
+ const { result, requests } = run(
101
+ CreateCustomer({
102
+ name: "Jenny Rosen",
103
+ balance: -500,
104
+ validate: false,
105
+ metadata: { order_id: "6735", "note with space": "a&b=c" },
106
+ shipping: {
107
+ name: "Jenny",
108
+ address: { city: "San Francisco", line1: "510 Townsend St" },
109
+ },
110
+ preferred_locales: ["en", "fr"],
111
+ expand: ["default_source"],
112
+ tax_id_data: [
113
+ { type: "eu_vat", value: "DE123" },
114
+ { type: "us_ein", value: "12-345" },
115
+ ],
116
+ }),
117
+ () => json({ id: "cus_1", object: "customer" }),
118
+ );
119
+ expect(await result).toMatchObject({ id: "cus_1" });
120
+
121
+ const [request] = requests;
122
+ expect(request!.method).toBe("POST");
123
+ expect(request!.url.href).toBe(`${BASE_URL}/v1/customers`);
124
+ expect(request!.url.search).toBe("");
125
+ expect(request!.body).toMatchObject({ contentType: "application/x-www-form-urlencoded" });
126
+ // Members follow the schema's declaration order.
127
+ expect(formPairs(request!)).toEqual([
128
+ ["balance", "-500"],
129
+ ["expand[0]", "default_source"],
130
+ ["metadata[order_id]", "6735"],
131
+ ["metadata[note with space]", "a&b=c"],
132
+ ["name", "Jenny Rosen"],
133
+ ["preferred_locales[0]", "en"],
134
+ ["preferred_locales[1]", "fr"],
135
+ ["shipping[name]", "Jenny"],
136
+ ["shipping[address][city]", "San Francisco"],
137
+ ["shipping[address][line1]", "510 Townsend St"],
138
+ ["tax_id_data[0][type]", "eu_vat"],
139
+ ["tax_id_data[0][value]", "DE123"],
140
+ ["tax_id_data[1][type]", "us_ein"],
141
+ ["tax_id_data[1][value]", "12-345"],
142
+ ["validate", "false"],
143
+ ]);
144
+ // Brackets are percent-encoded on the wire.
145
+ expect(textOf(request!)).toContain("metadata%5Border_id%5D=6735");
146
+ });
147
+
148
+ test("true encodes as `true`; null and undefined members are omitted", async () => {
149
+ const { result, requests } = run(
150
+ CreateCustomer({
151
+ validate: true,
152
+ email: undefined,
153
+ metadata: { kept: "1", dropped: null as unknown as string },
154
+ }),
155
+ );
156
+ await result;
157
+ expect(formPairs(requests[0]!)).toEqual([
158
+ ["metadata[kept]", "1"],
159
+ ["validate", "true"],
160
+ ]);
161
+ });
162
+
163
+ test("an empty string is sent (Stripe's way to unset a field)", async () => {
164
+ const { result, requests } = run(CreateCustomer({ description: "", metadata: "" }));
165
+ await result;
166
+ expect(formPairs(requests[0]!)).toEqual([
167
+ ["description", ""],
168
+ ["metadata", ""],
169
+ ]);
170
+ });
171
+
172
+ test("input keys unknown to the schema pass through as body fields", async () => {
173
+ const { result, requests } = run(
174
+ CreateCustomer({ name: "x", future_param: { a: 1 } } as Parameters<typeof CreateCustomer>[0]),
175
+ );
176
+ await result;
177
+ expect(formPairs(requests[0]!)).toEqual([
178
+ ["name", "x"],
179
+ ["future_param[a]", "1"],
180
+ ]);
181
+ });
182
+
183
+ test("an empty input sends no body", async () => {
184
+ const { result, requests } = run(CreateCustomer({}));
185
+ await result;
186
+ expect(requests[0]!.body._tag).toBe("Empty");
187
+ });
188
+
189
+ test("labels are substituted and percent-encoded", async () => {
190
+ const { result, requests } = run(DeleteProduct({ id: "prod/1 2" }), () =>
191
+ json({ id: "prod/1 2", deleted: true, object: "product" }),
192
+ );
193
+ await result;
194
+ expect(requests[0]!.method).toBe("DELETE");
195
+ expect(requests[0]!.url.pathname).toBe("/v1/products/prod%2F1%202");
196
+ expect(requests[0]!.body._tag).toBe("Empty");
197
+ });
198
+ });
199
+
200
+ describe("Query encoding (GET /v1)", () => {
201
+ test("nested query objects use brackets and arrays repeat `key[]`", async () => {
202
+ const { result, requests } = run(
203
+ GetCustomers({
204
+ created: { gte: 1700000000, lt: 1800000000 },
205
+ expand: ["data.default_source", "data.tax"],
206
+ limit: 3,
207
+ email: "a+b@example.test",
208
+ }),
209
+ () => json({ object: "list", data: [], has_more: false, url: "/v1/customers" }),
210
+ );
211
+ expect(await result).toMatchObject({ object: "list", data: [], has_more: false });
212
+
213
+ const [request] = requests;
214
+ expect(request!.method).toBe("GET");
215
+ expect(request!.url.pathname).toBe("/v1/customers");
216
+ expect([...request!.url.searchParams.entries()]).toEqual([
217
+ ["created[gte]", "1700000000"],
218
+ ["created[lt]", "1800000000"],
219
+ ["email", "a+b@example.test"],
220
+ ["expand[]", "data.default_source"],
221
+ ["expand[]", "data.tax"],
222
+ ["limit", "3"],
223
+ ]);
224
+ expect(request!.body._tag).toBe("Empty");
225
+ });
226
+
227
+ test("a label plus query on a single-resource GET", async () => {
228
+ const { result, requests } = run(
229
+ GetCustomer({ customer: "cus_123", expand: ["sources"] }),
230
+ () => json({ id: "cus_123", object: "customer" }),
231
+ );
232
+ await result;
233
+ expect(requests[0]!.url.pathname).toBe("/v1/customers/cus_123");
234
+ expect([...requests[0]!.url.searchParams.entries()]).toEqual([["expand[]", "sources"]]);
235
+ });
236
+
237
+ test("GET inputs never carry a body: unknown keys flatten into the query", async () => {
238
+ const { result, requests } = run(
239
+ GetCustomers({ limit: 1, extra: { nested: true }, tags: ["a"] } as Parameters<
240
+ typeof GetCustomers
241
+ >[0]),
242
+ () => json({ object: "list", data: [], has_more: false, url: "/v1/customers" }),
243
+ );
244
+ await result;
245
+ expect([...requests[0]!.url.searchParams.entries()]).toEqual([
246
+ ["limit", "1"],
247
+ ["extra[nested]", "true"],
248
+ ["tags[]", "a"],
249
+ ]);
250
+ expect(requests[0]!.body._tag).toBe("Empty");
251
+ });
252
+ });
253
+
254
+ describe("JSON (/v2) and multipart bodies", () => {
255
+ test("/v2 operations send a JSON body", async () => {
256
+ const { result, requests } = run(
257
+ CreateBillingMeterEvent2({
258
+ event_name: "api_calls",
259
+ payload: { stripe_customer_id: "cus_1", value: "25" },
260
+ }),
261
+ () => json({ object: "v2.billing.meter_event", event_name: "api_calls" }),
262
+ );
263
+ await result;
264
+ const [request] = requests;
265
+ expect(request!.url.pathname).toBe("/v2/billing/meter_events");
266
+ expect(request!.body).toMatchObject({ contentType: "application/json" });
267
+ expect(JSON.parse(textOf(request!)!)).toEqual({
268
+ event_name: "api_calls",
269
+ payload: { stripe_customer_id: "cus_1", value: "25" },
270
+ });
271
+ expect(request!.headers["authorization"]).toBe(`Bearer ${API_KEY}`);
272
+ });
273
+
274
+ test("POST /v1/files is multipart form-data with the file part", async () => {
275
+ const file = new File(["%PDF-1.4"], "evidence.pdf", { type: "application/pdf" });
276
+ const { result, requests } = run(
277
+ CreateFile({ file: file as unknown as string, purpose: "dispute_evidence" }),
278
+ () => json({ id: "file_1", object: "file" }),
279
+ );
280
+ await result;
281
+ const [request] = requests;
282
+ expect(request!.method).toBe("POST");
283
+ expect(request!.url.href).toBe(`${BASE_URL}/v1/files`);
284
+ expect(request!.headers["authorization"]).toBe(`Bearer ${API_KEY}`);
285
+ expect(request!.headers["accept"]).toBe("application/json");
286
+ if (request!.body._tag !== "FormData") throw new Error("expected multipart");
287
+ const form = request!.body.formData;
288
+ expect(form.get("purpose")).toBe("dispute_evidence");
289
+ const part = form.get("file") as File;
290
+ expect(part.name).toBe("evidence.pdf");
291
+ expect(await part.text()).toBe("%PDF-1.4");
292
+ });
293
+ });
294
+
295
+ describe("Request headers", () => {
296
+ test("Authorization is the bearer API key and Accept is JSON", async () => {
297
+ const { result, requests } = run(GetCustomer({ customer: "cus_1" }));
298
+ await result;
299
+ expect(requests[0]!.headers["authorization"]).toBe(`Bearer ${API_KEY}`);
300
+ expect(requests[0]!.headers["accept"]).toBe("application/json");
301
+ expect(requests[0]!.headers["idempotency-key"]).toBeUndefined();
302
+ expect(requests[0]!.headers["stripe-version"]).toBeUndefined();
303
+ expect(requests[0]!.headers["stripe-account"]).toBeUndefined();
304
+ });
305
+
306
+ test("withRequestOptions sets Idempotency-Key, Stripe-Version and Stripe-Account", async () => {
307
+ const { result, requests } = run(
308
+ CreateCustomer({ name: "x" }).pipe(
309
+ withRequestOptions({
310
+ idempotencyKey: "idem-1",
311
+ apiVersion: "2025-01-27.acacia",
312
+ stripeAccount: "acct_123",
313
+ }),
314
+ ),
315
+ );
316
+ await result;
317
+ expect(requests[0]!.headers).toMatchObject({
318
+ "idempotency-key": "idem-1",
319
+ "stripe-version": "2025-01-27.acacia",
320
+ "stripe-account": "acct_123",
321
+ });
322
+ expect(requests[0]!.headers["stripe-context"]).toBeUndefined();
323
+ });
324
+
325
+ test("withRequestOptions sets Stripe-Context", async () => {
326
+ const { result, requests } = run(
327
+ GetCustomer({ customer: "cus_1" }).pipe(withRequestOptions({ stripeContext: "ctx_1" })),
328
+ );
329
+ await result;
330
+ expect(requests[0]!.headers["stripe-context"]).toBe("ctx_1");
331
+ expect(requests[0]!.headers["stripe-account"]).toBeUndefined();
332
+ });
333
+
334
+ test("multipart requests carry the request options too", async () => {
335
+ const { result, requests } = run(
336
+ CreateFile({
337
+ file: new File(["x"], "x.txt") as unknown as string,
338
+ purpose: "dispute_evidence",
339
+ }).pipe(withRequestOptions({ idempotencyKey: "idem-file" })),
340
+ );
341
+ await result;
342
+ expect(requests[0]!.headers["idempotency-key"]).toBe("idem-file");
343
+ });
344
+ });
345
+
346
+ describe("Error envelope decoding", () => {
347
+ const envelope = (error: Record<string, unknown>) => ({ error });
348
+ const call = () => GetCustomer({ customer: "cus_1" });
349
+
350
+ test("card_error maps to CardError with the card fields", async () => {
351
+ const error = await runError(call(), () =>
352
+ json(
353
+ envelope({
354
+ type: "card_error",
355
+ code: "card_declined",
356
+ decline_code: "insufficient_funds",
357
+ message: "Your card has insufficient funds.",
358
+ charge: "ch_1",
359
+ param: "card",
360
+ doc_url: "https://stripe.com/docs/error-codes/card-declined",
361
+ advice_code: "try_again_later",
362
+ payment_method_type: "card",
363
+ request_log_url: "https://dashboard.stripe.com/logs/req_1",
364
+ }),
365
+ 402,
366
+ ),
367
+ );
368
+ expect(error).toBeInstanceOf(CardError);
369
+ expect(error).toMatchObject({
370
+ code: "card_declined",
371
+ decline_code: "insufficient_funds",
372
+ message: "Your card has insufficient funds.",
373
+ charge: "ch_1",
374
+ param: "card",
375
+ advice_code: "try_again_later",
376
+ payment_method_type: "card",
377
+ request_log_url: "https://dashboard.stripe.com/logs/req_1",
378
+ });
379
+ });
380
+
381
+ test("idempotency_error, invalid_request_error and api_error map by type", async () => {
382
+ const idempotency = await runError(call(), () =>
383
+ json(envelope({ type: "idempotency_error", message: "Keys reused." }), 400),
384
+ );
385
+ expect(idempotency).toBeInstanceOf(IdempotencyError);
386
+ expect(idempotency).toMatchObject({ message: "Keys reused." });
387
+
388
+ const invalid = await runError(call(), () =>
389
+ json(
390
+ envelope({
391
+ type: "invalid_request_error",
392
+ code: "resource_missing",
393
+ message: "No such customer: 'cus_1'",
394
+ param: "id",
395
+ }),
396
+ 404,
397
+ ),
398
+ );
399
+ expect(invalid).toBeInstanceOf(InvalidRequestError);
400
+ expect(invalid).toMatchObject({
401
+ code: "resource_missing",
402
+ message: "No such customer: 'cus_1'",
403
+ param: "id",
404
+ });
405
+
406
+ const api = await runError(call(), () =>
407
+ json(envelope({ type: "api_error", message: "Something went wrong." }), 500),
408
+ );
409
+ expect(api).toBeInstanceOf(ApiError);
410
+ });
411
+
412
+ test("an unrecognised type falls back to 402 / 424 Stripe statuses", async () => {
413
+ const payment = await runError(call(), () =>
414
+ json(
415
+ envelope({ type: "other_error", code: "c", message: "m", decline_code: "d", param: "p" }),
416
+ 402,
417
+ ),
418
+ );
419
+ expect(payment).toBeInstanceOf(PaymentError);
420
+ expect(payment).toMatchObject({ code: "c", message: "m", decline_code: "d", param: "p" });
421
+
422
+ const dependency = await runError(call(), () =>
423
+ json(envelope({ type: "other_error", message: "upstream" }), 424),
424
+ );
425
+ expect(dependency).toBeInstanceOf(ExternalDependencyFailed);
426
+ });
427
+
428
+ test("an unrecognised type falls back to core status classes with Retry-After", async () => {
429
+ const throttled = await runError(call(), () =>
430
+ json(envelope({ type: "rate_limit", message: "Too many." }), 429, { "Retry-After": "4" }),
431
+ );
432
+ expect(throttled).toBeInstanceOf(TooManyRequests);
433
+ expect(throttled).toMatchObject({ message: "Too many." });
434
+ expect(Duration.toSeconds((throttled as TooManyRequests).retryAfter!)).toBe(4);
435
+
436
+ const notFound = await runError(call(), () =>
437
+ json(envelope({ type: "other_error", message: "gone" }), 404),
438
+ );
439
+ expect(notFound).toBeInstanceOf(NotFound);
440
+ });
441
+
442
+ test("an unmapped 5xx is InternalServerError", async () => {
443
+ const error = await runError(call(), () =>
444
+ json(envelope({ type: "other_error", message: "proxy" }), 520),
445
+ );
446
+ expect(error).toBeInstanceOf(InternalServerError);
447
+ expect(error).toMatchObject({ message: "proxy" });
448
+ });
449
+
450
+ test("an unrecognised type at an unmapped 4xx is UnknownStripeError with the fields", async () => {
451
+ const body = envelope({ type: "other_error", code: "c", message: "m", param: "p" });
452
+ const error = await runError(call(), () => json(body, 418));
453
+ expect(error).toBeInstanceOf(UnknownStripeError);
454
+ expect(error).toMatchObject({ type: "other_error", code: "c", message: "m", param: "p", body });
455
+ });
456
+
457
+ test("a body without the envelope is UnknownStripeError with the raw body", async () => {
458
+ const html = await runError(
459
+ call(),
460
+ () => new Response("<html>bad gateway</html>", { status: 502 }),
461
+ );
462
+ expect(html).toBeInstanceOf(UnknownStripeError);
463
+ expect(html).toMatchObject({ body: "<html>bad gateway</html>" });
464
+
465
+ const other = await runError(call(), () => json({ message: "nope" }, 400));
466
+ expect(other).toBeInstanceOf(UnknownStripeError);
467
+ expect(other).toMatchObject({ body: { message: "nope" } });
468
+ });
469
+
470
+ test("per-operation matchers win over the envelope type", async () => {
471
+ const message =
472
+ "This product cannot be deleted because it has one or more user-created prices.";
473
+ const error = await runError(DeleteProduct({ id: "prod_1" }), () =>
474
+ json(envelope({ type: "invalid_request_error", message }), 400),
475
+ );
476
+ expect(error).toBeInstanceOf(ProductHasPrices);
477
+ expect(error).toMatchObject({ message });
478
+ });
479
+
480
+ test("per-operation matchers need their status and message", async () => {
481
+ const otherMessage = await runError(DeleteProduct({ id: "prod_1" }), () =>
482
+ json(envelope({ type: "invalid_request_error", message: "No such product" }), 400),
483
+ );
484
+ expect(otherMessage).toBeInstanceOf(InvalidRequestError);
485
+
486
+ const otherStatus = await runError(DeleteProduct({ id: "prod_1" }), () =>
487
+ json(envelope({ type: "invalid_request_error", message: "has user-created prices" }), 409),
488
+ );
489
+ expect(otherStatus).toBeInstanceOf(InvalidRequestError);
490
+ });
491
+ });
492
+
493
+ describe("Success decoding", () => {
494
+ test("the 2xx body is the payload, with no envelope", async () => {
495
+ const customer = { id: "cus_1", object: "customer", email: "a@example.test" };
496
+ const { result } = run(GetCustomer({ customer: "cus_1" }), () => json(customer));
497
+ expect(await result).toEqual(customer);
498
+ });
499
+
500
+ // GetCustomer's output is a Customer | DeletedCustomer union, so strict
501
+ // checks use CreateCustomer (output: Customer).
502
+ test("strict mode fails a mismatched payload with StripeParseError", async () => {
503
+ const { result } = run(
504
+ CreateCustomer({}).pipe(Effect.provide(ResponseValidation.strict), Effect.flip),
505
+ () => json({ id: 42 }),
506
+ );
507
+ expect(await result).toBeInstanceOf(StripeParseError);
508
+ });
509
+
510
+ test("a non-JSON 2xx: lenient returns the text, strict fails", async () => {
511
+ const lenient = run(CreateCustomer({}), () => new Response("ok"));
512
+ expect((await lenient.result) as unknown).toBe("ok");
513
+ const strict = run(
514
+ CreateCustomer({}).pipe(Effect.provide(ResponseValidation.strict), Effect.flip),
515
+ () => new Response("ok"),
516
+ );
517
+ expect(await strict.result).toBeInstanceOf(StripeParseError);
518
+ });
519
+ });