Skip to content
Source
Sign inSign up
Open History to see the last commit

cliproxyApi.test.ts

703 lines25.0 KB

1import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import { describe, expect, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";
5import * as Schema from "effect/Schema";
6import * as TestClock from "effect/testing/TestClock";
7import { HttpClient, HttpClientError, HttpClientResponse } from "effect/http";
8​
9import { makeCliproxyApi } from "./cliproxyApi.ts";
10​
11const config = {
12 kind: "cliproxy",
13 url: "http://hub.test:8317",
14 managementKey: "management-secret",
15 enabled: true,
16} as const;
17const accounts = [
18 {
19 id: "first.json",
20 auth_index: "a",
21 provider: "codex",
22 email: "first@example.com",
23 id_token: { chatgpt_account_id: "account-a" },
24 },
25 {
26 id: "second.json",
27 auth_index: "b",
28 provider: "codex",
29 email: "second@example.com",
30 id_token: { chatgpt_account_id: "account-b" },
31 },
32];
33const credit = (id: string, expires_at = "2099-01-01T00:00:00Z") => ({
34 id,
35 expires_at,
36 status: "available",
37 reset_type: "codex_rate_limits",
38});
39const RequestBody = Schema.Struct({
40 auth_index: Schema.String,
41 method: Schema.optional(Schema.String),
42 url: Schema.optional(Schema.String),
43 header: Schema.optional(Schema.Record(Schema.String, Schema.String)),
44 data: Schema.optional(Schema.String),
45});
46type RequestBody = typeof RequestBody.Type;
47const decodeRequest = Schema.decodeUnknownSync(Schema.fromJsonString(RequestBody));
48const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
49​
50function fixture(
51 options: {
52 accounts?: Array<{
53 id: string;
54 auth_index: string;
55 provider: string;
56 email?: string;
57 disabled?: boolean;
58 project_id?: string;
59 id_token?: { chatgpt_account_id: string; plan_type?: string; chatgpt_plan_type?: string };
60 }>;
61 upstream?: (request: RequestBody) => { status: number; body: unknown };
62 cooldownStatus?: number;
63 } = {},
64) {
65 const requests: Array<{ path: string; body?: RequestBody }> = [];
66 const http = HttpClient.make((request) =>
67 Effect.sync(() => {
68 expect(request.headers.authorization).toBe("Bearer management-secret");
69 const path = new URL(request.url).pathname;
70 const body =
71 request.body._tag === "Uint8Array"
72 ? decodeRequest(new TextDecoder().decode(request.body.body))
73 : undefined;
74 requests.push({ path, ...(body ? { body } : {}) });
75 if (path.endsWith("/auth-files"))
76 return HttpClientResponse.fromWeb(
77 request,
78 Response.json({ files: options.accounts ?? accounts }),
79 );
80 if (path.endsWith("/reset-quota"))
81 return HttpClientResponse.fromWeb(
82 request,
83 Response.json({}, { status: options.cooldownStatus ?? 200 }),
84 );
85 expect(path).toMatch(/\/v0\/management\/api-call$/);
86 expect(body?.header?.Authorization).toBe("Bearer $TOKEN$");
87 const upstream = options.upstream?.(body!) ?? {
88 status: 200,
89 body: body?.url?.endsWith("/consume")
90 ? { code: "reset" }
91 : body?.url?.endsWith("/rate-limit-reset-credits")
92 ? {
93 credits: [
94 credit("later", "2099-02-01T00:00:00Z"),
95 credit("first"),
96 credit("expired", "2000-01-01T00:00:00Z"),
97 { ...credit("used"), status: "redeemed" },
98 ],
99 }
100 : {
101 plan_type: "pro",
102 rate_limit: {
103 secondary_window: {
104 used_percent: 78,
105 reset_at: 4070908800,
106 limit_window_seconds: 604800,
107 },
108 },
109 },
110 };
111 return HttpClientResponse.fromWeb(
112 request,
113 Response.json({ status_code: upstream.status, body: encodeJson(upstream.body) }),
114 );
115 }),
116 );
117 return {
118 requests,
119 api: makeCliproxyApi.pipe(
120 Effect.provideService(HttpClient.HttpClient, http),
121 Effect.provide(NodeCrypto.layer),
122 ),
123 };
125​
126const unreachableHub = (
127 fail: (
128 request: Parameters<Parameters<typeof HttpClient.make>[0]>[0],
129 ) => HttpClientError.HttpClientError,
130) =>
131 makeCliproxyApi.pipe(
132 Effect.provideService(
133 HttpClient.HttpClient,
134 HttpClient.make((request) => Effect.fail(fail(request))),
135 ),
136 Effect.provide(NodeCrypto.layer),
137 );
138​
139describe("CLIProxyAPI hub failures", () => {
140 it.effect("names the TLS code when the hub's certificate is not trusted", () =>
141 Effect.gen(function* () {
142 const api = yield* unreachableHub(
143 (request) =>
144 new HttpClientError.HttpClientError({
145 reason: new HttpClientError.TransportError({
146 request,
147 // Node's fetch wraps the TLS error, which carries the code, in a generic TypeError.
148 cause: new TypeError("fetch failed", {
149 cause: Object.assign(new Error("unable to get local issuer certificate"), {
150 code: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
151 }),
152 }),
153 }),
154 }),
155 );
156 const error = yield* Effect.flip(api.readAccounts(config));
157 expect(error.detail).toContain("UNABLE_TO_GET_ISSUER_CERT_LOCALLY");
158 expect(error.detail).toContain("NODE_EXTRA_CA_CERTS");
159 }),
160 );
161​
162 // regression: #18168 Limits hub failures name their TLS, HTTP, or timeout cause
163 it.effect("ignores a transport code that is not a short Node error code", () =>
164 Effect.gen(function* () {
165 const api = yield* unreachableHub(
166 (request) =>
167 new HttpClientError.HttpClientError({
168 reason: new HttpClientError.TransportError({
169 request,
170 cause: Object.assign(new Error("odd"), { code: "X".repeat(200) }),
171 }),
172 }),
173 );
174 const error = yield* Effect.flip(api.readAccounts(config));
175 expect(error.detail).toBe("The hub management request failed.");
176 }),
177 );
178​
179 it.effect("names the status, not the body, when the hub rejects the request", () =>
180 Effect.gen(function* () {
181 const test = HttpClient.make((request) =>
182 Effect.succeed(
183 HttpClientResponse.fromWeb(
184 request,
185 Response.json({ error: "management-secret is wrong" }, { status: 401 }),
186 ),
187 ),
188 );
189 const api = yield* makeCliproxyApi.pipe(
190 Effect.provideService(HttpClient.HttpClient, test),
191 Effect.provide(NodeCrypto.layer),
192 );
193 const error = yield* Effect.flip(api.readAccounts(config));
194 expect(error.detail).toContain("401");
195 expect(error.detail).not.toContain("management-secret");
196 }),
197 );
198});
199​
200describe("CLIProxyAPI built-in management API", () => {
201 it.effect(
202 "reads both accounts and their earliest unexpired credits without plugin endpoints",
203 () =>
204 Effect.gen(function* () {
205 yield* TestClock.setTime(1788710400000);
206 const test = fixture();
207 const api = yield* test.api;
208 const result = yield* api.readAccounts(config);
209 expect(result.map((account) => account.usageLimits.resetCredits)).toEqual([
210 { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" },
211 { availableCount: 2, nextCreditId: "first", nextExpiresAt: "2099-01-01T00:00:00.000Z" },
212 ]);
213 expect(result[0]?.usageLimits.windows).toMatchObject([
214 { id: "secondary", usedPercent: 78, kind: "weekly" },
215 ]);
216 const calls = test.requests.filter((request) => request.body?.url);
217 expect(calls.map((request) => request.body?.auth_index).sort()).toEqual([
218 "a",
219 "a",
220 "b",
221 "b",
222 ]);
223 expect(
224 calls.find((request) => request.body?.auth_index === "b")?.body?.header?.[
225 "Chatgpt-Account-Id"
226 ],
227 ).toBe("account-b");
228 }),
229 );
230​
231 it.effect("resolves a missing usage plan from current, then legacy, token plans", () =>
232 Effect.gen(function* () {
233 const test = fixture({
234 accounts: [
235 { ...accounts[0]!, id_token: { chatgpt_account_id: "account-a", plan_type: "free" } },
236 {
237 ...accounts[1]!,
238 id_token: {
239 chatgpt_account_id: "account-b",
240 plan_type: "go",
241 chatgpt_plan_type: "plus",
242 },
243 },
244 {
245 ...accounts[1]!,
246 id: "third.json",
247 auth_index: "c",
248 id_token: { chatgpt_account_id: "account-c", chatgpt_plan_type: "free" },
249 },
250 ],
251 upstream: (request) => ({
252 status: 200,
253 body: {
254 ...(request.auth_index === "a" ? { plan_type: "plus" } : {}),
255 rate_limit: { primary_window: { used_percent: 12 } },
256 },
257 }),
258 });
259 const api = yield* test.api;
260 const result = yield* api.readAccounts(config);
261 expect(result.map((account) => [account.plan, account.usageLimits.windows[0]?.kind])).toEqual(
262 [
263 ["ChatGPT Plus Subscription", "session"],
264 ["ChatGPT Go Subscription", "monthly"],
265 ["ChatGPT Free Subscription", "monthly"],
266 ],
267 );
268 }),
269 );
270​
271 it.effect.each([
272 "https://tools.test/cliproxy",
273 "https://tools.test/cliproxy/",
274 "https://tools.test/cliproxy/management.html",
275 "https://tools.test/cliproxy/management.html/",
276 "https://tools.test/cliproxy/MANAGEMENT.HTML",
277 "https://tools.test/cliproxy/management.html?tab=accounts",
278 "https://tools.test/cliproxy/management.html#accounts",
279 "https://tools.test/cliproxy/management.html/?tab=accounts",
280 "https://tools.test/cliproxy/management.html/#accounts",
281 ])("keeps the path of a hub served under a prefix: %s", (url) =>
282 Effect.gen(function* () {
283 const test = fixture();
284 const api = yield* test.api;
285 yield* api.readAccounts({ ...config, url });
286 expect(test.requests.length).toBeGreaterThan(0);
287 expect(
288 test.requests.every((request) => request.path.startsWith("/cliproxy/v0/management/")),
289 ).toBe(true);
290 }),
291 );
292​
293 it.effect("preserves a hub path that only ends with the panel filename", () =>
294 Effect.gen(function* () {
295 const test = fixture();
296 const api = yield* test.api;
297 yield* api.readAccounts({ ...config, url: "https://tools.test/team-management.html" });
298 expect(test.requests.length).toBeGreaterThan(0);
299 expect(
300 test.requests.every((request) =>
301 request.path.startsWith("/team-management.html/v0/management/"),
302 ),
303 ).toBe(true);
304 }),
305 );
306​
307 it.effect("keeps usage when the credits endpoint fails", () =>
308 Effect.gen(function* () {
309 const test = fixture({
310 upstream: (request) =>
311 request.url?.endsWith("rate-limit-reset-credits")
312 ? { status: 503, body: { token: "do-not-publish" } }
313 : { status: 200, body: { rate_limit: { primary_window: { used_percent: 12 } } } },
314 });
315 const api = yield* test.api;
316 const result = yield* api.readAccounts(config);
317 expect(result[0]?.usageLimits.windows[0]?.usedPercent).toBe(12);
318 expect(result[0]?.usageLimits.resetCredits).toBeUndefined();
319 }),
320 );
321​
322 it.effect("isolates a failed account and never publishes upstream error bodies", () =>
323 Effect.gen(function* () {
324 const test = fixture({
325 upstream: (request) =>
326 request.auth_index === "a"
327 ? { status: 401, body: { token: "do-not-publish" } }
328 : {
329 status: 200,
330 body: request.url?.endsWith("rate-limit-reset-credits")
331 ? { credits: [] }
332 : { rate_limit: { primary_window: { used_percent: 12 } } },
333 },
334 });
335 const api = yield* test.api;
336 const result = yield* api.readAccounts(config);
337 expect(result[0]?.usageLimits.unavailable?.reason).toBe("probeFailed");
338 expect(result[1]?.usageLimits.windows[0]?.usedPercent).toBe(12);
339 expect(encodeJson(result)).not.toContain("do-not-publish");
340 }),
341 );
342​
343 it.effect("maps Claude scoped windows without a scheduler plugin", () =>
344 Effect.gen(function* () {
345 const test = fixture({
346 accounts: [{ ...accounts[0]!, provider: "claude" }],
347 // The usage endpoint answers 429 to requests without a Claude Code user agent.
348 upstream: (request) => ({
349 status: request.header?.["User-Agent"]?.startsWith("claude-code/") ? 200 : 429,
350 body: {
351 five_hour: { utilization: 10, resets_at: null },
352 seven_day: { utilization: 50, resets_at: "2099-01-01T00:00:00Z" },
353 limits: [
354 {
355 kind: "weekly_scoped",
356 percent: 80,
357 resets_at: null,
358 scope: { model: { display_name: "Fable" } },
359 },
360 ],
361 },
362 }),
363 });
364 const api = yield* test.api;
365 const result = yield* api.readAccounts(config);
366 expect(
367 result[0]?.usageLimits.windows.map((window) => [window.id, window.usedPercent]),
368 ).toEqual([
369 ["five_hour", 10],
370 ["seven_day", 50],
371 ["seven_day_fable", 80],
372 ]);
373 }),
374 );
375​
376 it.effect.each([
377 {
378 name: "spend budget",
379 budget: {
380 spend: {
381 enabled: true,
382 used: { amount_minor: 4_631, currency: "USD", exponent: 2 },
383 limit: { amount_minor: 50_000, currency: "USD", exponent: 2 },
384 },
385 },
386 },
387 {
388 name: "extra usage",
389 budget: {
390 extra_usage: {
391 is_enabled: true,
392 used_credits: 4_631,
393 monthly_limit: 50_000,
394 utilization: 9,
395 },
396 },
397 },
398 ])("keeps Claude monthly spend from $name beside rolling windows", ({ budget }) =>
399 Effect.gen(function* () {
400 const test = fixture({
401 accounts: [{ ...accounts[0]!, provider: "claude" }],
402 upstream: () => ({
403 status: 200,
404 body: { five_hour: { utilization: 10, resets_at: null }, ...budget },
405 }),
406 });
407 const result = yield* (yield* test.api).readAccounts(config);
408 expect(
409 result[0]?.usageLimits.windows.map((window) => [window.id, window.usedPercent]),
410 ).toEqual([
411 ["five_hour", 10],
412 ["monthly_spend", expect.closeTo(9.262, 10)],
413 ]);
414 }),
415 );
416​
417 it.effect("omits disabled Claude budgets without losing rolling windows", () =>
418 Effect.gen(function* () {
419 const test = fixture({
420 accounts: [{ ...accounts[0]!, provider: "claude" }],
421 upstream: () => ({
422 status: 200,
423 body: {
424 five_hour: { utilization: 10, resets_at: null },
425 spend: { enabled: false, used: null, limit: null },
426 extra_usage: {
427 is_enabled: false,
428 used_credits: null,
429 monthly_limit: null,
430 utilization: null,
431 },
432 },
433 }),
434 });
435 const result = yield* (yield* test.api).readAccounts(config);
436 expect(result[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["five_hour"]);
437 }),
438 );
439​
440 const antigravitySummary = {
441 buckets: [
442 {
443 bucketId: "gemini-weekly",
444 window: "weekly",
445 remainingFraction: 0.75,
446 resetTime: "2099-01-01T00:00:00Z",
447 },
448 ],
449 };
450​
451 it.effect("reads an xAI account as Grok with Grok CLI headers", () =>
452 Effect.gen(function* () {
453 const test = fixture({
454 accounts: [{ id: "xai.json", auth_index: "x", provider: "xai", email: "x@example.com" }],
455 upstream: () => ({
456 status: 200,
457 body: {
458 config: {
459 creditUsagePercent: 40,
460 currentPeriod: { type: "USAGE_PERIOD_TYPE_WEEKLY", end: "2099-01-01T00:00:00Z" },
461 },
462 },
463 }),
464 });
465 const [account] = yield* (yield* test.api).readAccounts(config);
466 expect(account).toMatchObject({ driver: "grok", email: "x@example.com" });
467 expect(account?.usageLimits.windows).toMatchObject([
468 { id: "subscription", kind: "weekly", usedPercent: 40 },
469 ]);
470 const call = test.requests.find((request) => request.body?.url)?.body;
471 expect(call?.method).toBe("GET");
472 expect(call?.url).toBe("https://cli-chat-proxy.grok.com/v1/billing?format=credits");
473 expect(call?.header).toMatchObject({
474 "x-xai-token-auth": "xai-grok-cli",
475 "x-grok-client-version": expect.any(String),
476 "User-Agent": expect.stringMatching(/^grok-cli\//),
477 });
478 }),
479 );
480​
481 it.effect("reads an Antigravity summary and keys the account by its Google ID", () =>
482 Effect.gen(function* () {
483 const test = fixture({
484 accounts: [
485 {
486 id: "antigravity-g@example.com.json",
487 auth_index: "g",
488 provider: "antigravity",
489 email: "g@example.com",
490 project_id: "project-7",
491 },
492 ],
493 upstream: (request) => ({
494 status: 200,
495 body: request.url?.endsWith("/userinfo") ? { id: "google-42" } : antigravitySummary,
496 }),
497 });
498 const [account] = yield* (yield* test.api).readAccounts(config);
499 expect(account?.driver).toBe("antigravity");
500 expect(account?.email).toBeUndefined();
501 expect(account?.usageLimits.credentialFingerprint).toBe(
502 // sha256("google-account\0google-42"), as a local Antigravity probe reports it.
503 "136d4952771fd67a3600053f37492cbd05828712e0b1c89ebab03d4c22ab6554",
504 );
505 expect(account?.usageLimits.windows).toMatchObject([
506 { id: "gemini-weekly", kind: "weekly", usedPercent: 25 },
507 ]);
508 const summary = test.requests.find((request) => request.body?.method === "POST")?.body;
509 expect(summary?.url).toBe(
510 "https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuotaSummary",
511 );
512 expect(summary?.data).toBe(encodeJson({ project: "project-7" }));
513 expect(summary).toMatchObject({
514 auth_index: "g",
515 method: "POST",
516 header: {
517 Authorization: "Bearer $TOKEN$",
518 "Content-Type": "application/json",
519 "User-Agent":
520 "antigravity/hub/2.24.0 (aidev_client; os_type=linux; arch=x64; cl=963137146)",
521 },
522 });
523 const identityCalls = test.requests.filter((request) =>
524 request.body?.url?.endsWith("/userinfo"),
525 );
526 expect(identityCalls).toHaveLength(1);
527 expect(identityCalls[0]?.body).toMatchObject({
528 method: "GET",
529 url: "https://www.googleapis.com/oauth2/v2/userinfo",
530 header: { Authorization: "Bearer $TOKEN$" },
531 });
532 }).pipe(
533 Effect.provideService(HostProcess.Platform, "linux"),
534 Effect.provideService(HostProcess.Architecture, "x64"),
535 ),
536 );
537​
538 it.effect("keeps Antigravity limits and listing email when its identity read fails", () =>
539 Effect.gen(function* () {
540 const test = fixture({
541 accounts: [
542 {
543 id: "antigravity.json",
544 auth_index: "g",
545 provider: "antigravity",
546 email: "g@example.com",
547 project_id: "project-7",
548 },
549 ],
550 upstream: (request) =>
551 request.url?.endsWith("/userinfo")
552 ? { status: 403, body: { error: "denied" } }
553 : {
554 status: 200,
555 body: antigravitySummary,
556 },
557 });
558 const result = yield* (yield* test.api).readAccounts(config);
559 expect(result).toHaveLength(1);
560 const [account] = result;
561 expect(account).toMatchObject({ driver: "antigravity", email: "g@example.com" });
562 expect(account?.usageLimits.credentialFingerprint).toBeUndefined();
563 expect(account?.usageLimits.unavailable).toBeUndefined();
564 expect(account?.usageLimits.windows).toMatchObject([
565 { id: "gemini-weekly", kind: "weekly", usedPercent: 25 },
566 ]);
567 }),
568 );
569​
570 it.effect.each([
571 { name: "a refused xAI call", provider: "xai", status: 403, body: { error: "denied" } },
572 { name: "a malformed xAI answer", provider: "xai", status: 200, body: { config: "x" } },
573 { name: "a refused Antigravity call", provider: "antigravity", status: 401, body: {} },
574 { name: "a malformed Antigravity answer", provider: "antigravity", status: 200, body: [] },
575 { name: "an empty Antigravity summary", provider: "antigravity", status: 200, body: {} },
576 ])("reports $name as unreadable", ({ provider, status, body }) =>
577 Effect.gen(function* () {
578 const test = fixture({
579 accounts: [{ id: `${provider}.json`, auth_index: "a", provider, project_id: "project-7" }],
580 upstream: () => ({ status, body }),
581 });
582 const [account] = yield* (yield* test.api).readAccounts(config);
583 expect(account?.usageLimits.unavailable?.reason).toBe("probeFailed");
584 }),
585 );
586​
587 it.effect.each([undefined, "", " "])(
588 "rejects a missing or blank Antigravity project (%s) before upstream calls",
589 (project_id) =>
590 Effect.gen(function* () {
591 const test = fixture({
592 accounts: [
593 {
594 id: "antigravity.json",
595 auth_index: "a",
596 provider: "antigravity",
597 ...(project_id === undefined ? {} : { project_id }),
598 },
599 ],
600 });
601 const [account] = yield* (yield* test.api).readAccounts(config);
602 expect(account?.usageLimits.unavailable?.reason).toBe("probeFailed");
603 expect(test.requests.map((request) => request.path)).toEqual(["/v0/management/auth-files"]);
604 }),
605 );
606​
607 it.effect("skips accounts of providers the hub table does not know", () =>
608 Effect.gen(function* () {
609 const test = fixture({
610 accounts: [
611 { id: "kiro.json", auth_index: "k", provider: "kiro", email: "k@example.com" },
612 { ...accounts[0]!, provider: "claude" },
613 ],
614 upstream: () => ({ status: 200, body: { five_hour: { utilization: 1, resets_at: null } } }),
615 });
616 const result = yield* (yield* test.api).readAccounts(config);
617 expect(result.map((account) => account.driver)).toEqual(["claudeAgent"]);
618 expect(test.requests.map((request) => request.body?.auth_index)).not.toContain("k");
619 }),
620 );
621​
622 it.effect("pins redemption to the displayed credit and clears only that account's cooldown", () =>
623 Effect.gen(function* () {
624 const test = fixture();
625 const api = yield* test.api;
626 expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" });
627 expect(yield* api.consume(config, "second.json", "credit-b")).toEqual({ outcome: "reset" });
628 const redemptions = test.requests.filter((request) =>
629 request.body?.url?.endsWith("/consume"),
630 );
631 expect(redemptions).toHaveLength(2);
632 expect(redemptions[0]?.body?.data).toBe(redemptions[1]?.body?.data);
633 expect(redemptions[0]?.body?.data).toBe(
634 encodeJson({
635 redeem_request_id: "519d5243-011a-5b7b-91f3-44d85f095705",
636 credit_id: "credit-b",
637 }),
638 );
639 expect(
640 test.requests
641 .filter((request) => request.path.endsWith("/reset-quota"))
642 .map((request) => request.body?.auth_index),
643 ).toEqual(["b", "b"]);
644 }),
645 );
646​
647 it.effect("reports redemption success even if cooldown clearing fails", () =>
648 Effect.gen(function* () {
649 const api = yield* fixture({ cooldownStatus: 404 }).api;
650 const result = yield* api.consume(config, "first.json", "credit");
651 expect(result.outcome).toBe("reset");
652 expect(result.warning).toContain("cooldown");
653 }),
654 );
655​
656 it.effect("skips disabled accounts and rejects redemption on them", () =>
657 Effect.gen(function* () {
658 const test = fixture({ accounts: [{ ...accounts[0]!, disabled: true }] });
659 const api = yield* test.api;
660 expect(yield* api.readAccounts(config)).toEqual([]);
661 expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe(
662 "Failure",
663 );
664 expect(test.requests.every((request) => request.path.endsWith("/auth-files"))).toBe(true);
665 }),
666 );
667​
668 it.effect("keeps the same redemption id after an uncertain upstream failure", () =>
669 Effect.gen(function* () {
670 let attempts = 0;
671 const test = fixture({
672 upstream: () =>
673 ++attempts === 1
674 ? { status: 503, body: {} }
675 : { status: 200, body: { code: "already_redeemed" } },
676 });
677 const api = yield* test.api;
678 expect((yield* api.consume(config, "first.json", "credit").pipe(Effect.result))._tag).toBe(
679 "Failure",
680 );
681 expect(yield* api.consume(config, "first.json", "credit")).toEqual({
682 outcome: "alreadyRedeemed",
683 });
684 const data = test.requests
685 .filter((request) => request.body?.url?.endsWith("/consume"))
686 .map((request) => request.body?.data);
687 expect(data[0]).toBe(data[1]);
688 expect(test.requests.filter((request) => request.path.endsWith("/reset-quota"))).toHaveLength(
689 1,
690 );
691 }),
692 );
693​
694 it.effect("rejects unknown accounts without forwarding a redemption", () =>
695 Effect.gen(function* () {
696 const test = fixture();
697 const api = yield* test.api;
698 const result = yield* api.consume(config, "missing.json", "credit").pipe(Effect.result);
699 expect(result._tag).toBe("Failure");
700 expect(test.requests).toHaveLength(1);
701 }),
702 );
703});