1import { Query } from "@distilled.cloud/core/query";
2import {
3 Railway as RailwayApi,
4 type UsageLimitSetInput,
5} from "@distilled.cloud/railway";
6import * as Provider from "@/Provider";
7import * as Railway from "@/Railway";
8import * as Test from "@/Test/Alchemy";
9import { expect } from "alchemy-test";
10import * as Effect from "effect/Effect";
11import { MinimumLogLevel } from "effect/References";
12import * as Result from "effect/Result";
13import * as Schedule from "effect/Schedule";
14
15const { test } = Test.make({ providers: Railway.providers() });
16
17const logLevel = Effect.provideService(
18 MinimumLogLevel,
19 process.env.DEBUG ? "Debug" : "Info",
20);
21
22const SOFT_V1 = 100_000;
23const SOFT_V2 = 100_001;
24
25const readCustomerId = Query.fn((workspaceId: string) => {
26 const workspace = RailwayApi.workspace({ workspaceId });
27 return { customer: { id: workspace.customer.id } };
28});
29
30const readUsageLimit = Query.fn((workspaceId: string) => {
31 const customer = RailwayApi.workspace({ workspaceId }).customer;
32 return {
33 customer: {
34 id: customer.id,
35 usageLimit: customer.usageLimit.pipe(
36 Query.map((limit) => ({ id: limit.id, softLimit: limit.softLimit })),
37 ),
38 },
39 };
40});
41
42const setUsageLimit = Query.fn((input: UsageLimitSetInput) =>
43 RailwayApi.usageLimitSet({ input }),
44);
45
46const removeUsageLimit = Query.fn((customerId: string) =>
47 RailwayApi.usageLimitRemove({ input: { customerId } }),
48);
49
50const waitUntilLimitGone = (workspaceId: string, usageLimitId: string) =>
51 readUsageLimit(workspaceId).pipe(
52 Effect.map((workspace) => {
53 const limit = workspace.customer.usageLimit;
54 if (limit == null) return "gone" as const;
55 return limit.id === usageLimitId ? ("found" as const) : ("gone" as const);
56 }),
57 Effect.catchTag("RailwayNotFound", () => Effect.succeed("gone" as const)),
58 Effect.repeat({
59 schedule: Schedule.spaced("1 second"),
60 until: (status) => status === "gone",
61 times: 10,
62 }),
63 );
64
65test.provider(
66 "usage() returns rows for the current workspace",
67 (stack) =>
68 Effect.gen(function* () {
69 yield* stack.destroy();
70
71 const rows = yield* Railway.usage({
72 measurements: ["CPU_USAGE", "MEMORY_USAGE_GB"],
73 });
74 expect(Array.isArray(rows)).toEqual(true);
75 for (const row of rows) {
76 expect(row.measurement).toEqual(expect.any(String));
77 expect(row.value).toEqual(expect.any(Number));
78 }
79
80 const estimated = yield* Railway.estimatedUsage({
81 measurements: ["CPU_USAGE"],
82 });
83 expect(Array.isArray(estimated)).toEqual(true);
84 for (const row of estimated) {
85 expect(row.measurement).toEqual(expect.any(String));
86 expect(row.estimatedValue).toEqual(expect.any(Number));
87 }
88
89 yield* stack.destroy();
90 }).pipe(logLevel),
91 {
92 tags: ["provider:railway", "provider:railway:usage", "live"],
93 timeout: 120_000,
94 },
95);
96
97test.provider(
98 "create, update, list, and delete a usage limit",
99 (stack) =>
100 Effect.gen(function* () {
101 yield* stack.destroy();
102
103 const workspace = yield* Railway.currentWorkspace();
104 const live = yield* readCustomerId(workspace.id);
105 const customerId = live.customer.id;
106 expect(customerId.length).toBeGreaterThan(0);
107
108 const probe = yield* Effect.result(
109 setUsageLimit({
110 customerId,
111 softLimitDollars: SOFT_V1,
112 }),
113 );
114 if (Result.isFailure(probe)) {
115 expect(
116 ["RailwayForbidden", "RailwayPlanLimitExceeded"].includes(
117 probe.failure._tag,
118 ),
119 ).toEqual(true);
120 yield* stack.destroy();
121 return;
122 }
123
124 // `usageLimitRemove` is idempotent; it declares no not-found error.
125 yield* removeUsageLimit(customerId);
126
127 const created = yield* stack.deploy(
128 Effect.gen(function* () {
129 return yield* Railway.UsageLimit("SpendCap", {
130 workspace: { workspaceId: workspace.id },
131 limit: SOFT_V1,
132 });
133 }),
134 );
135
136 expect(created.usageLimitId).toEqual(expect.any(String));
137 expect(created.usageLimitId.length).toBeGreaterThan(0);
138 expect(created.customerId).toEqual(customerId);
139 expect(created.workspaceId).toEqual(workspace.id);
140 expect(created.softLimitDollars).toEqual(SOFT_V1);
141 expect(created.isOverLimit).toEqual(expect.any(Boolean));
142
143 const fetched = yield* readUsageLimit(created.workspaceId);
144 expect(fetched.customer.id).toEqual(created.customerId);
145 expect(fetched.customer.usageLimit?.id).toEqual(created.usageLimitId);
146 expect(fetched.customer.usageLimit?.softLimit).toEqual(SOFT_V1);
147
148 const provider = yield* Provider.findProvider(Railway.UsageLimit);
149 const listed = yield* provider.list();
150 const found = listed.find(
151 (row) => row.usageLimitId === created.usageLimitId,
152 );
153 expect(found).toBeDefined();
154 expect(found?.customerId).toEqual(created.customerId);
155 expect(found?.softLimitDollars).toEqual(SOFT_V1);
156
157 const updated = yield* stack.deploy(
158 Effect.gen(function* () {
159 return yield* Railway.UsageLimit("SpendCap", {
160 workspace: { workspaceId: workspace.id },
161 limit: SOFT_V2,
162 });
163 }),
164 );
165
166 expect(updated.customerId).toEqual(created.customerId);
167 expect(updated.workspaceId).toEqual(created.workspaceId);
168 expect(updated.softLimitDollars).toEqual(SOFT_V2);
169
170 const fetchedUpdate = yield* readUsageLimit(updated.workspaceId);
171 expect(fetchedUpdate.customer.usageLimit?.softLimit).toEqual(SOFT_V2);
172
173 yield* stack.destroy();
174
175 const gone = yield* waitUntilLimitGone(
176 created.workspaceId,
177 created.usageLimitId,
178 );
179 expect(gone).toEqual("gone");
180 }).pipe(logLevel),
181 {
182 tags: ["provider:railway", "provider:railway:usage", "live"],
183 timeout: 120_000,
184 },
185);