1import * as Alchemy from "@/index.ts";
2import * as Cloudflare from "@/Cloudflare";
3import * as Stripe from "@/Stripe";
4import * as Test from "@/Test/Alchemy";
5import { expect } from "alchemy-test";
6import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientRequest from "effect/http/HttpClientRequest";
12import StripeEventSourceWorker from "./fixtures/event-source-worker.ts";
13
14const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
15 providers: Layer.mergeAll(Cloudflare.providers(), Stripe.providers()),
16});
17const { executeWhenReady, getWhenReady } = Test;
18
19const logLevel = Effect.provideService(
20 MinimumLogLevel,
21 process.env.DEBUG ? "Debug" : "Info",
22);
23
24const Stack = Alchemy.Stack(
25 "StripeEventSourceTestStack",
26 {
27 providers: Layer.mergeAll(Cloudflare.providers(), Stripe.providers()),
28 state: Cloudflare.state(),
29 },
30 Effect.gen(function* () {
31 const worker = yield* StripeEventSourceWorker;
32 return { url: worker.url.as<string>() };
33 }),
34);
35
36const stack = beforeAll(deploy(Stack));
37afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
38
39// Real delivery to a fresh workers.dev URL is slow async provisioning
40// (edge propagation + Stripe retry backoff + KV eventual consistency,
41// ~1-4 min under full-suite load), so it is opt-in. The stripe-billing
42// example integ exercises the same end-to-end delivery in its own run.
43test.skipIf(process.env.STRIPE_TEST_REAL_DELIVERY !== "1")(
44 "consumeEvents records a CustomerCreated delivery",
45 Effect.gen(function* () {
46 const { url } = yield* stack;
47 const base = url.replace(/\/+$/, "");
48 yield* getWhenReady(`${base}/last`);
49
50 // Ride out binding cold-start: the Stripe CreateCustomer token may not
51 // be visible on the first request after a fresh deploy.
52 const created = yield* executeWhenReady(
53 HttpClientRequest.post(`${base}/customers`),
54 );
55 expect(created.status).toBe(201);
56 const body = (yield* created.json) as { id: string };
57 expect(body.id).toMatch(/^cus_/);
58
59 // Stripe delivers asynchronously to the fresh workers.dev URL and KV
60 // reads are eventually consistent, so poll up to ~3 minutes, absorbing
61 // transient transport errors during edge propagation.
62 const id = yield* Effect.gen(function* () {
63 const res = yield* HttpClient.execute(
64 HttpClientRequest.get(`${base}/last/${body.id}`),
65 );
66 if (res.status !== 200) return null;
67 const json = (yield* res.json) as { id: string | null };
68 return json.id;
69 }).pipe(
70 Effect.catch(() => Effect.succeed(null)),
71 Effect.repeat({
72 schedule: Schedule.spaced("5 seconds"),
73 until: (value) => value === body.id,
74 times: 36,
75 }),
76 );
77 expect(id).toEqual(body.id);
78 }).pipe(logLevel),
79 {
80 tags: [
81 "provider:cloudflare",
82 "provider:cloudflare:kv",
83 "provider:cloudflare:worker",
84 "provider:stripe",
85 "provider:stripe:customer",
86 "live",
87 ],
88 timeout: 240_000,
89 },
90);
91
92test(
93 "invalid Stripe-Signature is rejected",
94 Effect.gen(function* () {
95 const { url } = yield* stack;
96 const base = url.replace(/\/+$/, "");
97 yield* getWhenReady(`${base}/last`);
98 const res = yield* HttpClient.execute(
99 HttpClientRequest.post(`${base}/webhooks/stripe`).pipe(
100 HttpClientRequest.setHeader("stripe-signature", "t=1,v1=00"),
101 HttpClientRequest.bodyText("{}"),
102 ),
103 );
104 expect(res.status).toBe(401);
105 }).pipe(logLevel),
106 {
107 tags: [
108 "provider:cloudflare",
109 "provider:cloudflare:kv",
110 "provider:cloudflare:worker",
111 "provider:stripe",
112 "provider:stripe:customer",
113 "live",
114 ],
115 timeout: 120_000,
116 },
117);