| 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 | */ |
| 11 | import * as Cloudflare from "@/Cloudflare"; |
| 12 | import * as Test from "@/Test/Alchemy"; |
| 13 | import { expect } from "alchemy-test"; |
| 14 | import * as Effect from "effect/Effect"; |
| 15 | import { MinimumLogLevel } from "effect/References"; |
| 16 | import * as Result from "effect/Result"; |
| 17 | import * as Schedule from "effect/Schedule"; |
| 18 | import * as HttpClient from "effect/http/HttpClient"; |
| 19 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 20 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 21 | import { GitApi, type Oid } from "@/Git/Api.ts"; |
| 22 | import { makeTestStack, TEST_SECRET } from "./fixtures/stack.ts"; |
| 23 | |
| 24 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 25 | providers: Cloudflare.providers(), |
| 26 | }); |
| 27 | |
| 28 | const logLevel = Effect.provideService( |
| 29 | MinimumLogLevel, |
| 30 | process.env.DEBUG ? "Debug" : "Info", |
| 31 | ); |
| 32 | |
| 33 | const 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 | */ |
| 42 | const 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 | |
| 59 | const 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 | |
| 67 | const asOid = (oid: string): Oid => oid as Oid; |
| 68 | |
| 69 | const 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 | */ |
| 88 | const 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. |
| 115 | const 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 | ); |
| 126 | afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); |
| 127 | |
| 128 | test( |
| 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 | |
| 203 | test( |
| 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 | ); |