| 1 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; |
| 5 | import * as Ref from "effect/Ref"; |
| 6 | |
| 7 | import * as ResetCreditCoordinator from "./resetCreditCoordinator.ts"; |
| 8 | |
| 9 | describe("ResetCreditCoordinator", () => { |
| 10 | it.effect("re-sends the same idempotency key after a failed attempt, then clears it", () => |
| 11 | Effect.gen(function* () { |
| 12 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; |
| 13 | const keys = yield* Ref.make<ReadonlyArray<string>>([]); |
| 14 | const attempts = yield* Ref.make(0); |
| 15 | const consume = (key: string) => |
| 16 | Effect.gen(function* () { |
| 17 | yield* Ref.update(keys, (seen) => [...seen, key]); |
| 18 | const attempt = yield* Ref.updateAndGet(attempts, (n) => n + 1); |
| 19 | if (attempt === 1) return yield* Effect.fail("timed out" as const); |
| 20 | return "reset" as const; |
| 21 | }); |
| 22 | |
| 23 | const first = yield* redeem("acct", consume).pipe(Effect.result); |
| 24 | assert.isTrue(first._tag === "Failure"); |
| 25 | const second = yield* redeem("acct", consume); |
| 26 | assert.strictEqual(second, "reset"); |
| 27 | // A fresh redemption after success must be a fresh attempt. |
| 28 | yield* redeem("acct", consume); |
| 29 | |
| 30 | const seen = yield* Ref.get(keys); |
| 31 | assert.strictEqual(seen.length, 3); |
| 32 | assert.strictEqual(seen[0], seen[1]); |
| 33 | assert.notStrictEqual(seen[1], seen[2]); |
| 34 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), |
| 35 | ); |
| 36 | |
| 37 | it.effect("starts a fresh attempt after a settled failure", () => |
| 38 | Effect.gen(function* () { |
| 39 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; |
| 40 | const keys = yield* Ref.make<ReadonlyArray<string>>([]); |
| 41 | const consume = (key: string) => |
| 42 | Ref.update(keys, (seen) => [...seen, key]).pipe( |
| 43 | Effect.andThen(Effect.fail("cooldown" as const)), |
| 44 | ); |
| 45 | const isSettled = (error: "cooldown") => error === "cooldown"; |
| 46 | |
| 47 | yield* redeem("acct", consume, isSettled).pipe(Effect.result); |
| 48 | yield* redeem("acct", consume, isSettled).pipe(Effect.result); |
| 49 | |
| 50 | const seen = yield* Ref.get(keys); |
| 51 | assert.strictEqual(seen.length, 2); |
| 52 | assert.notStrictEqual(seen[0], seen[1]); |
| 53 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), |
| 54 | ); |
| 55 | |
| 56 | it.effect("serialises concurrent redemptions on the same account, not per caller", () => |
| 57 | Effect.gen(function* () { |
| 58 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; |
| 59 | const release = yield* Deferred.make<void>(); |
| 60 | const inFlight = yield* Ref.make(0); |
| 61 | const peak = yield* Ref.make(0); |
| 62 | const consume = () => |
| 63 | Effect.gen(function* () { |
| 64 | const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); |
| 65 | yield* Ref.update(peak, (p) => Math.max(p, now)); |
| 66 | yield* Deferred.await(release); |
| 67 | yield* Ref.update(inFlight, (n) => n - 1); |
| 68 | return "reset" as const; |
| 69 | }); |
| 70 | |
| 71 | // Two instances of the same account redeem at once. |
| 72 | const a = yield* redeem("acct", consume).pipe(Effect.forkChild); |
| 73 | const b = yield* redeem("acct", consume).pipe(Effect.forkChild); |
| 74 | yield* Effect.yieldNow; |
| 75 | yield* Deferred.succeed(release, undefined); |
| 76 | yield* Fiber.join(a); |
| 77 | yield* Fiber.join(b); |
| 78 | |
| 79 | assert.strictEqual(yield* Ref.get(peak), 1); |
| 80 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), |
| 81 | ); |
| 82 | |
| 83 | it.effect("keeps different accounts independent", () => |
| 84 | Effect.gen(function* () { |
| 85 | const { redeem } = yield* ResetCreditCoordinator.ResetCreditCoordinator; |
| 86 | const release = yield* Deferred.make<void>(); |
| 87 | const peak = yield* Ref.make(0); |
| 88 | const inFlight = yield* Ref.make(0); |
| 89 | const consume = () => |
| 90 | Effect.gen(function* () { |
| 91 | const now = yield* Ref.updateAndGet(inFlight, (n) => n + 1); |
| 92 | yield* Ref.update(peak, (p) => Math.max(p, now)); |
| 93 | yield* Deferred.await(release); |
| 94 | return "reset" as const; |
| 95 | }); |
| 96 | const a = yield* redeem("acct-a", consume).pipe(Effect.forkChild); |
| 97 | const b = yield* redeem("acct-b", consume).pipe(Effect.forkChild); |
| 98 | yield* Effect.yieldNow; |
| 99 | yield* Deferred.succeed(release, undefined); |
| 100 | yield* Fiber.join(a); |
| 101 | yield* Fiber.join(b); |
| 102 | assert.strictEqual(yield* Ref.get(peak), 2); |
| 103 | }).pipe(Effect.provide(ResetCreditCoordinator.layerTest)), |
| 104 | ); |
| 105 | }); |