1/**
2 * Tier-2 deployed REST lifecycle suite (DESIGN.md §9): drives the typed
3 * `HttpApiClient` against a real Cloudflare deployment of the git-service
4 * stack. Deterministic repo names with a pre-test purge (delete-if-exists),
5 * bounded edge-propagation retries on first contact, `NO_DESTROY=1` keeps
6 * the deployment for local iteration.
7 *
8 * The full wire-protocol coverage lives in `GitProtocol.e2e.test.ts`; the
9 * local dev-mode mirror of this suite is `GitService.local.test.ts`.
10 */
11import * as Cloudflare from "@/Cloudflare";
12import * as Test from "@/Test/Alchemy";
13import { expect } from "alchemy-test";
14import * as Effect from "effect/Effect";
15import { MinimumLogLevel } from "effect/References";
16import * as Result from "effect/Result";
17import * as Schedule from "effect/Schedule";
18import * as HttpClient from "effect/http/HttpClient";
19import * as HttpClientRequest from "effect/http/HttpClientRequest";
20import * as HttpApiClient from "effect/http-api/HttpApiClient";
21import { GitApi, type Oid } from "@/Git/Api.ts";
22import { makeTestStack, TEST_SECRET } from "./fixtures/stack.ts";
23
24const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
25 providers: Cloudflare.providers(),
26});
27
28const logLevel = Effect.provideService(
29 MinimumLogLevel,
30 process.env.DEBUG ? "Debug" : "Info",
31);
32
33const Stack = makeTestStack("GitServiceTestStack");
34
35// ── edge readiness ──────────────────────────────────────────────────────────
36
37/**
38 * A fresh Cloudflare deploy is eventually consistent across PoPs: retry
39 * transport failures and the 404/5xx window, but never a decoded domain
40 * error (those are the assertions).
41 */
42const edgeRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
43 effect.pipe(
44 Effect.timeout("10 seconds"),
45 Effect.retry({
46 while: (error: unknown) =>
47 (error as { _tag?: string })._tag === "TimeoutError" ||
48 ((error as { _tag?: string })._tag === "HttpClientError" &&
49 (!(error as { response?: { status: number } }).response ||
50 (error as { response: { status: number } }).response.status ===
51 404 ||
52 (error as { response: { status: number } }).response.status >=
53 500)),
54 schedule: Schedule.spaced("1500 millis"),
55 times: 40,
56 }),
57 );
58
59const makeClient = (url: string, token: string) =>
60 HttpApiClient.make(GitApi, {
61 baseUrl: url,
62 transformClient: HttpClient.mapRequest((request) =>
63 request.pipe(HttpClientRequest.bearerToken(token)),
64 ),
65 });
66
67const asOid = (oid: string): Oid => oid as Oid;
68
69const expectTag = <A, E extends { readonly _tag: string }, R>(
70 effect: Effect.Effect<A, E, R>,
71 tag: string,
72) =>
73 Effect.gen(function* () {
74 const result = yield* Effect.result(effect);
75 expect(Result.isFailure(result)).toBe(true);
76 if (Result.isFailure(result)) {
77 expect(result.failure._tag).toBe(tag);
78 return result.failure;
79 }
80 return yield* Effect.die("unreachable");
81 });
82
83/**
84 * Delete-if-exists and wait until the name is free again (delete is an async
85 * purge: `status: "deleting"` → 404). Keeps repo names deterministic across
86 * runs without leaking 409s from interrupted prior runs.
87 */
88const purgeRepo = Effect.fn(function* (
89 url: string,
90 owner: string,
91 repo: string,
92) {
93 const admin = yield* makeClient(url, TEST_SECRET);
94 // edgeRetry on every step: a freshly deployed workers.dev route serves
95 // transient non-JSON 404s/5xx for a few seconds (typed 404s decode fine
96 // and are NOT retried).
97 yield* admin.repos.delete({ params: { owner, repo } }).pipe(
98 Effect.catchTag("RepoNotFound", () => Effect.void),
99 edgeRetry,
100 );
101 yield* admin.repos.get({ params: { owner, repo } }).pipe(
102 edgeRetry,
103 Effect.as(false),
104 Effect.catchTag("RepoNotFound", () => Effect.succeed(true)),
105 Effect.repeat({
106 schedule: Schedule.spaced("1 second"),
107 until: (gone) => gone,
108 times: 60,
109 }),
110 );
111});
112
113// Warm the deployment through the edge-propagation window once, so tests can
114// call the API directly.
115const stack = beforeAll(
116 deploy(Stack).pipe(
117 Effect.tap(({ url }) =>
118 Effect.gen(function* () {
119 const admin = yield* makeClient(url, TEST_SECRET);
120 yield* admin.repos.list({ query: {} }).pipe(edgeRetry);
121 }),
122 ),
123 logLevel,
124 ),
125);
126afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
127
128test(
129 "repo lifecycle: create → read back → 409 → patch → delete → 404",
130 Effect.gen(function* () {
131 const { url } = yield* stack;
132 const admin = yield* makeClient(url, TEST_SECRET);
133 yield* purgeRepo(url, "e2e", "rest-lifecycle");
134
135 const created = yield* admin.repos.create({
136 payload: { owner: "e2e", name: "rest-lifecycle", description: "tier2" },
137 });
138 expect(created.repo.status).toBe("ready");
139 expect(created.repo.defaultBranch).toBe("main");
140 expect(created.remote).toContain("/e2e/rest-lifecycle.git");
141
142 // readable immediately after create (one-round-trip design goal)
143 const read = yield* admin.repos.get({
144 params: { owner: "e2e", repo: "rest-lifecycle" },
145 });
146 expect(read.repoId).toBe(created.repo.repoId);
147
148 yield* expectTag(
149 admin.repos.create({ payload: { owner: "e2e", name: "rest-lifecycle" } }),
150 "RepoAlreadyExists",
151 );
152
153 const patched = yield* admin.repos.update({
154 params: { owner: "e2e", repo: "rest-lifecycle" },
155 payload: { description: "updated", readOnly: true },
156 });
157 expect(patched.description).toBe("updated");
158 expect(patched.readOnly).toBe(true);
159
160 // list-all (admin only) sees it
161 const listed = yield* admin.repos.list({ query: { owner: "e2e" } });
162 expect(listed.items.some((repo) => repo.name === "rest-lifecycle")).toBe(
163 true,
164 );
165
166 // refs are empty on a fresh repo
167 const refs = yield* admin.refs.list({
168 params: { owner: "e2e", repo: "rest-lifecycle" },
169 query: {},
170 });
171 expect(refs.refs).toEqual([]);
172
173 // delete → NoContent now, async purge → typed 404
174 yield* admin.repos.delete({
175 params: { owner: "e2e", repo: "rest-lifecycle" },
176 });
177 const gone = yield* admin.repos
178 .get({ params: { owner: "e2e", repo: "rest-lifecycle" } })
179 .pipe(
180 Effect.map((repo) => ({ deleted: false, status: repo.status })),
181 Effect.catchTag("RepoNotFound", () =>
182 Effect.succeed({ deleted: true as const, status: undefined }),
183 ),
184 Effect.repeat({
185 schedule: Schedule.spaced("2 seconds"),
186 until: (state) => state.deleted,
187 times: 45,
188 }),
189 );
190 expect(gone.deleted).toBe(true);
191 }).pipe(logLevel),
192 {
193 tags: [
194 "provider:cloudflare",
195 "provider:cloudflare:r2",
196 "provider:cloudflare:worker",
197 "live",
198 ],
199 timeout: 180_000,
200 },
201);
202
203test(
204 "REST ref writes: typed ObjectNotFound / ReadOnlyRepo / RefNotFound on an empty repo",
205 Effect.gen(function* () {
206 const { url } = yield* stack;
207 const admin = yield* makeClient(url, TEST_SECRET);
208 yield* purgeRepo(url, "e2e", "rest-refs");
209 yield* admin.repos.create({ payload: { owner: "e2e", name: "rest-refs" } });
210 const params = { owner: "e2e", repo: "rest-refs" };
211
212 // pointing a ref at an object the repo does not have → typed 404
213 yield* expectTag(
214 admin.refs.update({
215 params,
216 query: { name: "refs/heads/main" },
217 payload: { newOid: asOid("f".repeat(40)) },
218 }),
219 "ObjectNotFound",
220 );
221
222 // readOnly wins over everything else on the write path
223 yield* admin.repos.update({ params, payload: { readOnly: true } });
224 yield* expectTag(
225 admin.refs.update({
226 params,
227 query: { name: "refs/heads/main" },
228 payload: { newOid: asOid("f".repeat(40)) },
229 }),
230 "ReadOnlyRepo",
231 );
232 yield* admin.repos.update({ params, payload: { readOnly: false } });
233
234 // reads of missing refs/objects decode as the tagged classes
235 yield* expectTag(
236 admin.refs.get({ params, query: { name: "refs/heads/main" } }),
237 "RefNotFound",
238 );
239 yield* expectTag(
240 admin.objects.commit({
241 params: { ...params, oid: asOid("e".repeat(40)) },
242 }),
243 "ObjectNotFound",
244 );
245 yield* expectTag(
246 admin.repos.get({ params: { owner: "e2e", repo: "never-created" } }),
247 "RepoNotFound",
248 );
249
250 yield* admin.repos.delete({ params });
251 }).pipe(logLevel),
252 {
253 tags: [
254 "provider:cloudflare",
255 "provider:cloudflare:r2",
256 "provider:cloudflare:worker",
257 "live",
258 ],
259 timeout: 120_000,
260 },
261);