1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as TestClock from "effect/testing/TestClock";
4
5import * as GitHubQuota from "./GitHubQuota.ts";
6import { CredentialScope } from "@t3tools/source-control-core/server/SourceControlRateLimit";
7
8const RESET = Date.parse("2099-08-13T14:00:00Z");
9const headers = (remaining: number, resource = "graphql", reset = RESET) => ({
10 "x-ratelimit-resource": resource,
11 "x-ratelimit-limit": "5000",
12 "x-ratelimit-remaining": String(remaining),
13 "x-ratelimit-reset": String(reset / 1000),
14});
15
16describe("GitHubQuota", () => {
17 it.effect("refuses a background request below the reserve, per quota and account", () =>
18 Effect.gen(function* () {
19 const quota = yield* GitHubQuota.GitHubQuota;
20 yield* quota.observe("github.com", headers(499));
21 const refused = yield* Effect.flip(quota.admit("github.com", "graphql"));
22 assert.strictEqual(refused.retryAt, RESET);
23 // Another quota, another host and another account each have their own balance.
24 yield* quota.admit("github.com", "core");
25 yield* quota.admit("ghe.example", "graphql");
26 yield* quota.admit("github.com", "graphql").pipe(Effect.provideService(CredentialScope, "b"));
27 yield* quota.admit("github.com", "graphql", { allowReserve: true });
28 }).pipe(Effect.provide(GitHubQuota.layer)),
29 );
30
31 it.effect("takes the latest balance in a window, and ignores an older window", () =>
32 Effect.gen(function* () {
33 const quota = yield* GitHubQuota.GitHubQuota;
34 yield* quota.observe("github.com", headers(400));
35 yield* Effect.flip(quota.admit("github.com", "graphql"));
36 // GitHub can report more left later in the same window, from another region.
37 yield* quota.observe("github.com", headers(900));
38 yield* quota.admit("github.com", "graphql");
39 yield* quota.observe("github.com", headers(400));
40 yield* Effect.flip(quota.admit("github.com", "graphql"));
41 // An answer from an older window says nothing about this one.
42 yield* quota.observe("github.com", headers(4000, "graphql", RESET - 3_600_000));
43 yield* Effect.flip(quota.admit("github.com", "graphql"));
44 // A later window replaces it.
45 yield* quota.observe("github.com", headers(4000, "graphql", RESET + 3_600_000));
46 yield* quota.admit("github.com", "graphql");
47 }).pipe(Effect.provide(GitHubQuota.layer)),
48 );
49
50 it.effect("lets interactive requests spend the reserve but not an empty quota", () =>
51 Effect.gen(function* () {
52 const quota = yield* GitHubQuota.GitHubQuota;
53 yield* quota.observe("github.com", headers(1));
54 yield* quota.admit("github.com", "graphql", { allowReserve: true });
55 yield* quota.observe("github.com", headers(0));
56 const refused = yield* Effect.flip(
57 quota.admit("github.com", "graphql", { allowReserve: true }),
58 );
59 assert.strictEqual(refused.retryAt, RESET);
60 }).pipe(Effect.provide(GitHubQuota.layer)),
61 );
62
63 it.effect("spends nothing locally, so a free 304 cannot run the balance down", () =>
64 Effect.gen(function* () {
65 const quota = yield* GitHubQuota.GitHubQuota;
66 yield* quota.observe("github.com", headers(501, "core"));
67 // Conditional reads answer 304 with the balance unchanged, however many are sent.
68 for (let index = 0; index < 5; index++) {
69 yield* quota.admit("github.com", "core");
70 yield* quota.observe("github.com", headers(501, "core"));
71 }
72 yield* quota.admit("github.com", "core");
73 }).pipe(Effect.provide(GitHubQuota.layer)),
74 );
75
76 it.effect("forgets a balance once its window resets, and ignores answers naming no quota", () =>
77 Effect.gen(function* () {
78 const quota = yield* GitHubQuota.GitHubQuota;
79 yield* quota.observe("github.com", headers(0));
80 yield* TestClock.setTime(RESET);
81 yield* quota.admit("github.com", "graphql");
82 yield* quota.observe("other.example", { "x-ratelimit-remaining": "0" });
83 yield* quota.admit("other.example", "graphql");
84 }).pipe(Effect.provide(GitHubQuota.layer)),
85 );
86});