1import * as Provider from "@/Provider";
2import * as Stripe from "@/Stripe";
3import * as Test from "@/Test/Alchemy";
4import {
5 GetEntitlementsFeatures,
6 GetProductFeatures,
7 GetProductFeature,
8 CreateEntitlementsFeature,
9 UpdateEntitlementsFeature,
10 type EntitlementsFeature as StripeEntitlementsFeature,
11} from "@distilled.cloud/stripe/stripe";
12import { expect } from "alchemy-test";
13import * as Effect from "effect/Effect";
14import { MinimumLogLevel } from "effect/References";
15import * as Schedule from "effect/Schedule";
16import { isMissingStripeResource } from "@/Stripe/missing.ts";
17
18const { test } = Test.make({ providers: Stripe.providers() });
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25const isMissing = isMissingStripeResource;
26
27const waitUntilGone = (product: string, id: string) =>
28 GetProductFeatures({ product, limit: 100 }).pipe(
29 Effect.map((response) =>
30 response.data.some((feature) => feature.id === id)
31 ? ("found" as const)
32 : ("gone" as const),
33 ),
34 Effect.catchIf(isMissing, () => Effect.succeed("gone" as const)),
35 Effect.repeat({
36 schedule: Schedule.spaced("1 second"),
37 until: (status) => status === "gone",
38 times: 10,
39 }),
40 );
41
42/**
43 * Entitlements Features cannot be hard-deleted and cannot be updated once
44 * inactive. Create (or reuse an active row) via distilled so this suite is
45 * not blocked by the sibling resource's deactivate-only lifecycle.
46 */
47const ensureFeature = (lookupKey: string, name: string) =>
48 Effect.gen(function* () {
49 const active = yield* GetEntitlementsFeatures({
50 lookup_key: lookupKey,
51 archived: false,
52 limit: 1,
53 });
54 if (active.data[0] !== undefined) return active.data[0];
55 return yield* CreateEntitlementsFeature({
56 lookup_key: lookupKey,
57 name,
58 metadata: { [Stripe.alchemyMetadataKeys.stack]: "ProductFeatureTest" },
59 }).pipe(
60 Effect.catchIf(
61 (e) => e._tag === "InvalidRequestError",
62 (e) =>
63 GetEntitlementsFeatures({
64 lookup_key: lookupKey,
65 archived: false,
66 limit: 1,
67 }).pipe(
68 Effect.flatMap((res) =>
69 res.data[0] !== undefined
70 ? Effect.succeed(res.data[0])
71 : Effect.fail(e),
72 ),
73 ),
74 ),
75 );
76 });
77
78const archiveFeature = (id: string) =>
79 UpdateEntitlementsFeature({ id, active: false }).pipe(
80 Effect.catchIf(isMissing, () => Effect.void),
81 Effect.catchIf(
82 (e) => e._tag === "InvalidRequestError",
83 () => Effect.void,
84 ),
85 );
86
87const withFeatures = <A, E, R>(
88 features: ReadonlyArray<StripeEntitlementsFeature>,
89 body: Effect.Effect<A, E, R>,
90) =>
91 body.pipe(
92 Effect.ensuring(
93 Effect.forEach(
94 features,
95 (feature) => archiveFeature(feature.id).pipe(Effect.ignore),
96 {
97 concurrency: "unbounded",
98 discard: true,
99 },
100 ),
101 ),
102 );
103
104test.provider(
105 "create, update, and delete a product feature",
106 (stack) =>
107 Effect.gen(function* () {
108 yield* stack.destroy();
109
110 const feature = yield* ensureFeature(
111 "alchemy-pf-lifecycle-feat",
112 "Alchemy Product Feature Lifecycle",
113 );
114
115 yield* withFeatures(
116 [feature],
117 Effect.gen(function* () {
118 const created = yield* stack.deploy(
119 Effect.gen(function* () {
120 const product = yield* Stripe.Product("PFProduct", {
121 name: "Alchemy Product Feature Product",
122 });
123 const attachment = yield* Stripe.ProductFeature("Analytics", {
124 product: product.id,
125 entitlementFeature: feature.id,
126 });
127 return { product, attachment };
128 }),
129 );
130
131 expect(created.attachment.id).toMatch(/^prodft_/);
132 expect(created.attachment.product).toEqual(created.product.id);
133 expect(created.attachment.entitlementFeature).toEqual(feature.id);
134 expect(created.attachment.livemode).toEqual(false);
135
136 const fetched = yield* GetProductFeature({
137 product: created.attachment.product,
138 id: created.attachment.id,
139 });
140 expect(fetched.id).toEqual(created.attachment.id);
141 expect(fetched.entitlement_feature.id).toEqual(feature.id);
142 expect(fetched.livemode).toEqual(false);
143
144 const updated = yield* stack.deploy(
145 Effect.gen(function* () {
146 const product = yield* Stripe.Product("PFProduct", {
147 name: "Alchemy Product Feature Product",
148 });
149 const attachment = yield* Stripe.ProductFeature("Analytics", {
150 product: product.id,
151 entitlementFeature: feature.id,
152 });
153 return { product, attachment };
154 }),
155 );
156
157 expect(updated.attachment.id).toEqual(created.attachment.id);
158 expect(updated.attachment.product).toEqual(created.product.id);
159 expect(updated.attachment.entitlementFeature).toEqual(feature.id);
160
161 yield* stack.destroy();
162
163 const gone = yield* waitUntilGone(
164 created.attachment.product,
165 created.attachment.id,
166 );
167 expect(gone).toEqual("gone");
168 }),
169 );
170 }).pipe(logLevel),
171 {
172 tags: [
173 "provider:stripe",
174 "provider:stripe:entitlementsfeature",
175 "provider:stripe:product",
176 "provider:stripe:productfeature",
177 "live",
178 ],
179 timeout: 120_000,
180 },
181);
182
183test.provider(
184 "list enumerates the deployed product feature",
185 (stack) =>
186 Effect.gen(function* () {
187 yield* stack.destroy();
188
189 const feature = yield* ensureFeature(
190 "alchemy-pf-list-feat",
191 "Alchemy Product Feature List",
192 );
193
194 yield* withFeatures(
195 [feature],
196 Effect.gen(function* () {
197 const deployed = yield* stack.deploy(
198 Effect.gen(function* () {
199 const product = yield* Stripe.Product("PFListProduct", {
200 name: "Alchemy Product Feature List Product",
201 });
202 const attachment = yield* Stripe.ProductFeature("ListAttach", {
203 product: product.id,
204 entitlementFeature: feature.id,
205 });
206 return { product, attachment };
207 }),
208 );
209
210 const provider = yield* Provider.findProvider(Stripe.ProductFeature);
211 const all = yield* provider.list();
212 const found = all.find(
213 (attachment) => attachment.id === deployed.attachment.id,
214 );
215 expect(found).toBeDefined();
216 expect(found?.product).toEqual(deployed.product.id);
217 expect(found?.entitlementFeature).toEqual(feature.id);
218
219 yield* stack.destroy();
220
221 const gone = yield* waitUntilGone(
222 deployed.attachment.product,
223 deployed.attachment.id,
224 );
225 expect(gone).toEqual("gone");
226
227 const after = yield* provider.list();
228 expect(
229 after.find(
230 (attachment) => attachment.id === deployed.attachment.id,
231 ),
232 ).toBeUndefined();
233 }),
234 );
235 }).pipe(logLevel),
236 {
237 tags: [
238 "provider:stripe",
239 "provider:stripe:entitlementsfeature",
240 "provider:stripe:product",
241 "provider:stripe:productfeature",
242 "live",
243 ],
244 timeout: 120_000,
245 },
246);
247
248test.provider(
249 "replace when entitlement feature changes",
250 (stack) =>
251 Effect.gen(function* () {
252 yield* stack.destroy();
253
254 const featureA = yield* ensureFeature(
255 "alchemy-pf-replace-feat-a",
256 "Alchemy Product Feature Replace A",
257 );
258 const featureB = yield* ensureFeature(
259 "alchemy-pf-replace-feat-b",
260 "Alchemy Product Feature Replace B",
261 );
262
263 yield* withFeatures(
264 [featureA, featureB],
265 Effect.gen(function* () {
266 const created = yield* stack.deploy(
267 Effect.gen(function* () {
268 const product = yield* Stripe.Product("PFReplaceProduct", {
269 name: "Alchemy Product Feature Replace Product",
270 });
271 const attachment = yield* Stripe.ProductFeature("ReplaceAttach", {
272 product: product.id,
273 entitlementFeature: featureA.id,
274 });
275 return { product, attachment };
276 }),
277 );
278
279 expect(created.attachment.entitlementFeature).toEqual(featureA.id);
280
281 const replaced = yield* stack.deploy(
282 Effect.gen(function* () {
283 const product = yield* Stripe.Product("PFReplaceProduct", {
284 name: "Alchemy Product Feature Replace Product",
285 });
286 const attachment = yield* Stripe.ProductFeature("ReplaceAttach", {
287 product: product.id,
288 entitlementFeature: featureB.id,
289 });
290 return { product, attachment };
291 }),
292 );
293
294 expect(replaced.attachment.id).not.toEqual(created.attachment.id);
295 expect(replaced.attachment.product).toEqual(created.product.id);
296 expect(replaced.attachment.entitlementFeature).toEqual(featureB.id);
297
298 const newFetched = yield* GetProductFeature({
299 product: replaced.attachment.product,
300 id: replaced.attachment.id,
301 });
302 expect(newFetched.entitlement_feature.id).toEqual(featureB.id);
303
304 const oldGone = yield* waitUntilGone(
305 created.attachment.product,
306 created.attachment.id,
307 );
308 expect(oldGone).toEqual("gone");
309
310 yield* stack.destroy();
311
312 const gone = yield* waitUntilGone(
313 replaced.attachment.product,
314 replaced.attachment.id,
315 );
316 expect(gone).toEqual("gone");
317 }),
318 );
319 }).pipe(logLevel),
320 {
321 tags: [
322 "provider:stripe",
323 "provider:stripe:entitlementsfeature",
324 "provider:stripe:product",
325 "provider:stripe:productfeature",
326 "live",
327 ],
328 timeout: 120_000,
329 },
330);