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 { projectServices } 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 Result from "effect/Result";
12import * as Schedule from "effect/Schedule";
13
14const { test } = Test.make({ providers: Railway.providers() });
15
16const logLevel = Effect.provideService(
17 MinimumLogLevel,
18 process.env.DEBUG ? "Debug" : "Info",
19);
20
21const PUBLIC_TEMPLATE_CODE = "postgres";
22
23const readServiceDeletedAt = Query.fn((id: string) => ({
24 deletedAt: RailwayApi.service({ id }).deletedAt,
25}));
26
27const readServiceTemplateId = Query.fn((id: string) => ({
28 templateId: RailwayApi.service({ id }).templateId,
29}));
30
31const readTemplateByCode = Query.fn((code: string) => {
32 const template = RailwayApi.template({ code });
33 return {
34 id: template.id,
35 code: template.code,
36 name: template.name,
37 serializedConfig: template.serializedConfig,
38 };
39});
40
41const readTemplateById = Query.fn((id: string) => {
42 const template = RailwayApi.template({ id });
43 return { id: template.id, code: template.code };
44});
45
46const readTemplateSourceForProject = Query.fn((projectId: string) =>
47 RailwayApi.templateSourceForProject({ projectId }).pipe(
48 Query.map((template) => ({ id: template.id })),
49 ),
50);
51
52const waitUntilServiceGone = (serviceId: string) =>
53 readServiceDeletedAt(serviceId).pipe(
54 Effect.map((service) =>
55 service.deletedAt != null ? ("gone" as const) : ("found" 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 "lookup a well-known public template",
67 (stack) =>
68 Effect.gen(function* () {
69 yield* stack.destroy();
70
71 const fetched = yield* readTemplateByCode(PUBLIC_TEMPLATE_CODE);
72 expect(fetched.id).toEqual(expect.any(String));
73 expect(fetched.id.length).toBeGreaterThan(0);
74 expect(fetched.code).toEqual(PUBLIC_TEMPLATE_CODE);
75 expect(fetched.name).toEqual(expect.any(String));
76 expect(fetched.serializedConfig).toBeDefined();
77
78 yield* stack.destroy();
79 }).pipe(logLevel),
80 {
81 tags: ["provider:railway", "provider:railway:template", "live"],
82 timeout: 120_000,
83 },
84);
85
86test.provider(
87 "deploy, list, and delete a marketplace template",
88 (stack) =>
89 Effect.gen(function* () {
90 yield* stack.destroy();
91
92 const created = yield* stack.deploy(
93 Effect.gen(function* () {
94 const { project, environment } = yield* suitePartition;
95 const deployed = yield* Railway.Template("Postgres", {
96 templateId: PUBLIC_TEMPLATE_CODE,
97 project,
98 environment,
99 });
100 return { project, environment, deployed };
101 }),
102 );
103
104 expect(created.deployed.templateId).toEqual(expect.any(String));
105 expect(created.deployed.templateId.length).toBeGreaterThan(0);
106 expect(created.deployed.code).toEqual(PUBLIC_TEMPLATE_CODE);
107 expect(created.deployed.name).toEqual(expect.any(String));
108 expect(created.deployed.projectId).toEqual(created.project.projectId);
109 expect(created.deployed.environmentId).toEqual(
110 created.environment.environmentId,
111 );
112 expect(created.deployed.workspaceId).toEqual(created.project.workspaceId);
113 expect(created.deployed.ownsProject).toEqual(false);
114 expect(created.deployed.serviceIds.length).toBeGreaterThan(0);
115 expect(created.deployed.url).toEqual(
116 `https://railway.com/project/${created.project.projectId}`,
117 );
118
119 const live = yield* projectServices(
120 created.project.projectId,
121 (service) => ({ id: service.id, deletedAt: service.deletedAt }),
122 );
123 const liveIds = live
124 .filter((node) => node.deletedAt == null)
125 .map((node) => node.id);
126 for (const serviceId of created.deployed.serviceIds) {
127 expect(liveIds).toContain(serviceId);
128 }
129
130 const source = yield* readTemplateSourceForProject(
131 created.project.projectId,
132 ).pipe(
133 Effect.catchTag("RailwayForbidden", () => Effect.succeed(undefined)),
134 );
135 if (source != null) {
136 expect(source.id).toEqual(created.deployed.templateId);
137 }
138
139 const stamped = yield* readServiceTemplateId(
140 created.deployed.serviceIds[0]!,
141 );
142 if (stamped.templateId != null) {
143 expect(stamped.templateId).toEqual(created.deployed.templateId);
144 }
145
146 const provider = yield* Provider.findProvider(Railway.Template);
147 const listed = yield* provider.list();
148 const found = listed.find(
149 (row) =>
150 row.projectId === created.deployed.projectId &&
151 row.templateId === created.deployed.templateId,
152 );
153 expect(found).toBeDefined();
154 if (found === undefined) {
155 return yield* Effect.fail(
156 new Error("Deployed marketplace template was not listed"),
157 );
158 }
159 expect(found.templateId).toEqual(created.deployed.templateId);
160 expect(found.serviceIds.length).toBeGreaterThan(0);
161 if (found.code !== undefined) {
162 expect(found.code).toEqual(PUBLIC_TEMPLATE_CODE);
163 } else {
164 // Listing preserves service ownership even when Railway refuses
165 // marketplace metadata by ID. Confirm that omission against the API.
166 const metadata = yield* Effect.result(
167 readTemplateById(found.templateId),
168 );
169 expect(Result.isFailure(metadata)).toBe(true);
170 if (Result.isFailure(metadata)) {
171 expect(
172 ["RailwayForbidden", "RailwayNotFound"].includes(
173 metadata.failure._tag,
174 ),
175 ).toBe(true);
176 }
177 }
178
179 yield* stack.destroy();
180
181 for (const serviceId of created.deployed.serviceIds) {
182 const gone = yield* waitUntilServiceGone(serviceId);
183 expect(gone).toEqual("gone");
184 }
185 }).pipe(logLevel),
186 {
187 tags: [
188 "provider:railway",
189 "provider:railway:project",
190 "provider:railway:projectenvironment",
191 "provider:railway:service",
192 "provider:railway:template",
193 "live",
194 ],
195 timeout: 120_000,
196 },
197);