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