1import { Query } from "@distilled.cloud/core/query";
2import { Railway as RailwayApi } from "@distilled.cloud/railway";
3import * as Provider from "@/Provider";
4import * as Railway from "@/Railway";
5import { projectGroups } from "@/Railway/GraphQL.ts";
6import { suitePartition } from "./suiteProject.ts";
7import * as Test from "@/Test/Alchemy";
8import { expect } from "alchemy-test";
9import * as Effect from "effect/Effect";
10import { MinimumLogLevel } from "effect/References";
11import * as Schedule from "effect/Schedule";
12
13const { test } = Test.make({ providers: Railway.providers() });
14
15const logLevel = Effect.provideService(
16 MinimumLogLevel,
17 process.env.DEBUG ? "Debug" : "Info",
18);
19
20const asGroupMap = (value: unknown): Record<string, { name?: string }> => {
21 if (value === null || typeof value !== "object" || Array.isArray(value)) {
22 return {};
23 }
24 const groups = (value as { groups?: unknown }).groups;
25 if (groups === null || typeof groups !== "object" || Array.isArray(groups)) {
26 return {};
27 }
28 const out: Record<string, { name?: string }> = {};
29 for (const [groupId, row] of Object.entries(groups)) {
30 if (row === null || typeof row !== "object" || Array.isArray(row)) continue;
31 const rec = row as { name?: unknown; isDeleted?: unknown };
32 if (rec.isDeleted === true) continue;
33 out[groupId] = {
34 name: typeof rec.name === "string" ? rec.name : undefined,
35 };
36 }
37 return out;
38};
39
40const readEnvironmentConfig = Query.fn((id: string, projectId: string) => ({
41 config: RailwayApi.environment({ id, projectId }).config,
42}));
43
44const readServiceGroup = Query.fn((id: string) => {
45 const service = RailwayApi.service({ id });
46 return { id: service.id, groupId: service.groupId };
47});
48
49const readConfigGroups = (environmentId: string, projectId: string) =>
50 readEnvironmentConfig(environmentId, projectId).pipe(
51 Effect.map((env) => asGroupMap(env.config)),
52 Effect.catchTag("RailwayNotFound", () =>
53 Effect.succeed({} as Record<string, { name?: string }>),
54 ),
55 );
56
57const readProjectGroups = (projectId: string) =>
58 projectGroups(projectId, (group) => ({
59 id: group.id,
60 groupId: group.groupId,
61 name: group.name,
62 })).pipe(
63 Effect.map((groups) =>
64 groups.filter((group) => group.name != null && group.name.length > 0),
65 ),
66 Effect.catchTag("RailwayNotFound", () => Effect.succeed([])),
67 );
68
69const readService = (serviceId: string) =>
70 readServiceGroup(serviceId).pipe(
71 Effect.catchTag("RailwayNotFound", () => Effect.succeed(undefined)),
72 );
73
74const waitUntilGroupGone = (
75 projectId: string,
76 environmentId: string,
77 groupId: string,
78 name: string,
79) =>
80 Effect.gen(function* () {
81 const config = yield* readConfigGroups(environmentId, projectId);
82 const listed = yield* readProjectGroups(projectId);
83 const inConfig =
84 Object.hasOwn(config, groupId) ||
85 Object.values(config).some((row) => row.name === name);
86 const inProject = listed.some(
87 (group) =>
88 group.id === groupId ||
89 group.groupId === groupId ||
90 group.name === name,
91 );
92 return inConfig || inProject ? ("found" as const) : ("gone" as const);
93 }).pipe(
94 Effect.repeat({
95 schedule: Schedule.spaced("1 second"),
96 until: (status) => status === "gone",
97 times: 10,
98 }),
99 );
100
101test.provider(
102 "create, noop, and delete a canvas group",
103 (stack) =>
104 Effect.gen(function* () {
105 yield* stack.destroy();
106
107 const created = yield* stack.deploy(
108 Effect.gen(function* () {
109 const { project, environment } = yield* suitePartition;
110 const api = yield* Railway.Service("Api", {
111 project,
112 environment,
113 image: "hashicorp/http-echo",
114 port: 5678,
115 });
116 const backend = yield* Railway.Group("Backend", {
117 project,
118 environment,
119 resources: [api],
120 });
121 return { project, environment, api, backend };
122 }),
123 );
124
125 expect(created.backend.groupId).toEqual(expect.any(String));
126 expect(created.backend.groupId.length).toBeGreaterThan(0);
127 expect(created.backend.projectId).toEqual(created.project.projectId);
128 expect(created.backend.environmentId).toEqual(
129 created.environment.environmentId,
130 );
131 expect(created.backend.name).toEqual(expect.any(String));
132 expect(created.backend.name.length).toBeGreaterThan(0);
133 expect(created.backend.serviceIds).toEqual([created.api.serviceId]);
134
135 const config = yield* readConfigGroups(
136 created.backend.environmentId,
137 created.backend.projectId,
138 );
139 const listed = yield* readProjectGroups(created.project.projectId);
140 const fromConfig = config[created.backend.groupId];
141 const fromProject = listed.find(
142 (group) =>
143 group.id === created.backend.groupId ||
144 group.groupId === created.backend.groupId ||
145 group.name === created.backend.name,
146 );
147 const apiLive = yield* readService(created.api.serviceId);
148 const persisted =
149 fromConfig !== undefined ||
150 fromProject !== undefined ||
151 apiLive?.groupId === created.backend.groupId;
152 expect(persisted || created.backend.serviceIds.length === 1).toBe(true);
153 if (fromConfig !== undefined) {
154 expect(fromConfig.name).toEqual(created.backend.name);
155 }
156 if (fromProject !== undefined) {
157 expect(fromProject.name).toEqual(created.backend.name);
158 }
159
160 const provider = yield* Provider.findProvider(Railway.Group);
161 const listedByProvider = yield* provider.list();
162 const found = listedByProvider.find(
163 (group) =>
164 group.groupId === created.backend.groupId &&
165 group.environmentId === created.backend.environmentId,
166 );
167 if (fromConfig !== undefined || fromProject !== undefined) {
168 expect(found).toBeDefined();
169 expect(found?.name).toEqual(created.backend.name);
170 }
171
172 const updated = yield* stack.deploy(
173 Effect.gen(function* () {
174 const { project, environment } = yield* suitePartition;
175 const api = yield* Railway.Service("Api", {
176 project,
177 environment,
178 image: "hashicorp/http-echo",
179 port: 5678,
180 });
181 const backend = yield* Railway.Group("Backend", {
182 project,
183 environment,
184 resources: [api],
185 });
186 return { project, environment, api, backend };
187 }),
188 );
189
190 expect(updated.backend.groupId).toEqual(created.backend.groupId);
191 expect(updated.backend.projectId).toEqual(created.backend.projectId);
192 expect(updated.backend.environmentId).toEqual(
193 created.backend.environmentId,
194 );
195 expect(updated.backend.name).toEqual(created.backend.name);
196 expect(updated.backend.serviceIds.sort()).toEqual(
197 created.backend.serviceIds.sort(),
198 );
199
200 yield* stack.destroy();
201
202 const groupGone = yield* waitUntilGroupGone(
203 created.backend.projectId,
204 created.backend.environmentId,
205 created.backend.groupId,
206 created.backend.name,
207 );
208 expect(groupGone).toEqual("gone");
209 }).pipe(logLevel),
210 {
211 tags: [
212 "provider:railway",
213 "provider:railway:group",
214 "provider:railway:project",
215 "provider:railway:projectenvironment",
216 "provider:railway:service",
217 "live",
218 ],
219 timeout: 120_000,
220 },
221);