@distilled.cloud/expo-eas 1.0.0-rc.13 → 1.0.0-rc.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"traits.d.ts","sourceRoot":"","sources":["../src/traits.ts"],"names":[],"mappings":"AAUA,OAAO,EACL,IAAI,EACJ,MAAM,EACN,KAAK,EACL,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,UAAU,EACV,kBAAkB,EAClB,gBAAgB,EAChB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,UAAU,EACV,YAAY,EACZ,WAAW,EACX,WAAW,EACX,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,6BAA6B,CAAC;AAMrC;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;CACrC;AAED,eAAO,MAAM,eAAe,eAE3B,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,SAAS,OAAQ,cAAc,qDACP,CAAC;AAEtC,eAAO,MAAM,kBAAkB,eAE9B,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,MAAM,qDACC,CAAC;AAE3C,eAAO,MAAM,wBAAwB,eAEpC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,wDACiB,CAAC"}
1
+ {"version":3,"file":"traits.d.ts","sourceRoot":"","sources":["../src/traits.ts"],"names":[],"mappings":"AAUA,OAAO,EACL,IAAI,EACJ,MAAM,EACN,KAAK,EACL,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,UAAU,EACV,kBAAkB,EAClB,gBAAgB,EAChB,KAAK,SAAS,EACd,KAAK,YAAY,EACjB,UAAU,EACV,YAAY,EACZ,WAAW,EACX,WAAW,EACX,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,6BAA6B,CAAC;AAMrC;;;;;GAKG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,IAAI,EAAE,OAAO,GAAG,UAAU,CAAC;CACrC;AAED,eAAO,MAAM,eAAe,eAAqD,CAAC;AAElF;;;GAGG;AACH,eAAO,MAAM,SAAS,OAAQ,cAAc,qDAAwC,CAAC;AAErF,eAAO,MAAM,kBAAkB,eAAwD,CAAC;AAExF;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,MAAM,qDAA6C,CAAC;AAEvF,eAAO,MAAM,wBAAwB,eAEpC,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,wDAAuD,CAAC"}
package/lib/traits.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"traits.js","sourceRoot":"","sources":["../src/traits.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAE7D,OAAO,EACL,IAAI,EACJ,MAAM,EACN,KAAK,EACL,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,UAAU,EACV,kBAAkB,EAClB,gBAAgB,EAGhB,UAAU,EACV,YAAY,EACZ,WAAW,EACX,WAAW,EACX,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,6BAA6B,CAAC;AAkBrC,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,CACvC,sCAAsC,CACvC,CAAC;AAEF;;;GAGG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,EAAkB,EAAE,EAAE,CAC9C,cAAc,CAAC,eAAe,EAAE,EAAE,CAAC,CAAC;AAEtC,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC,GAAG,CAC1C,yCAAyC,CAC1C,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,IAAY,EAAE,EAAE,CAC3C,cAAc,CAAC,kBAAkB,EAAE,IAAI,CAAC,CAAC;AAE3C,MAAM,CAAC,MAAM,wBAAwB,GAAG,MAAM,CAAC,GAAG,CAChD,gDAAgD,CACjD,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,EAAE,CACrC,cAAc,CAAC,wBAAwB,EAAE,IAAI,CAAC,CAAC"}
1
+ {"version":3,"file":"traits.js","sourceRoot":"","sources":["../src/traits.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAE7D,OAAO,EACL,IAAI,EACJ,MAAM,EACN,KAAK,EACL,KAAK,EACL,IAAI,EACJ,YAAY,EACZ,QAAQ,EACR,YAAY,EACZ,aAAa,EACb,UAAU,EACV,kBAAkB,EAClB,gBAAgB,EAGhB,UAAU,EACV,YAAY,EACZ,WAAW,EACX,WAAW,EACX,UAAU,EACV,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,GACpB,MAAM,6BAA6B,CAAC;AAkBrC,MAAM,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,CAAC,sCAAsC,CAAC,CAAC;AAElF;;;GAGG;AACH,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,EAAkB,EAAE,EAAE,CAAC,cAAc,CAAC,eAAe,EAAE,EAAE,CAAC,CAAC;AAErF,MAAM,CAAC,MAAM,kBAAkB,GAAG,MAAM,CAAC,GAAG,CAAC,yCAAyC,CAAC,CAAC;AAExF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,YAAY,GAAG,CAAC,IAAY,EAAE,EAAE,CAAC,cAAc,CAAC,kBAAkB,EAAE,IAAI,CAAC,CAAC;AAEvF,MAAM,CAAC,MAAM,wBAAwB,GAAG,MAAM,CAAC,GAAG,CAChD,gDAAgD,CACjD,CAAC;AAEF;;;;;GAKG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,EAAE,CAAC,cAAc,CAAC,wBAAwB,EAAE,IAAI,CAAC,CAAC"}
package/package.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
2
  "name": "@distilled.cloud/expo-eas",
3
- "version": "1.0.0-rc.13",
3
+ "version": "1.0.0-rc.14",
4
+ "license": "Apache-2.0",
4
5
  "repository": {
5
6
  "type": "git",
6
7
  "url": "https://github.com/alchemy-run/distilled",
7
8
  "directory": "packages/expo-eas"
8
9
  },
9
- "license": "Apache-2.0",
10
- "type": "module",
11
- "sideEffects": false,
12
- "module": "src/index.ts",
13
10
  "files": [
14
11
  "lib",
15
12
  "src"
16
13
  ],
14
+ "type": "module",
15
+ "sideEffects": false,
16
+ "module": "src/index.ts",
17
17
  "exports": {
18
18
  ".": {
19
19
  "types": "./lib/index.d.ts",
@@ -65,20 +65,22 @@
65
65
  }
66
66
  },
67
67
  "dependencies": {
68
- "@distilled.cloud/core": "1.0.0-rc.13"
68
+ "@distilled.cloud/core": "1.0.0-rc.14"
69
69
  },
70
70
  "devDependencies": {
71
- "@types/bun": "1.3.14",
72
- "@types/node": "26.2.0",
73
- "@effect/platform-bun": "^4.0.0"
71
+ "@types/node": "26.2.0"
74
72
  },
75
73
  "peerDependencies": {
76
74
  "effect": "^4.0.0"
77
75
  },
76
+ "distilled": {
77
+ "generatedAt": "2026-10-05",
78
+ "specUpdatedAt": "2026-10-03"
79
+ },
78
80
  "scripts": {
79
81
  "build": "tsc -b",
80
- "convert": "bun scripts/convert.ts",
81
- "generate": "bun scripts/generate.ts",
82
+ "convert": "node --conditions=bun scripts/convert.ts",
83
+ "generate": "node --conditions=bun scripts/generate.ts",
82
84
  "typecheck": "tsc -p tsconfig.json",
83
85
  "specs:fetch": "git submodule update --force --init --depth=1 specs/spec-mirror-expo-eas",
84
86
  "specs:update": "git submodule update --remote --force --depth=1 specs/spec-mirror-expo-eas"
@@ -22,10 +22,9 @@ export interface Config {
22
22
  readonly apiBaseUrl: string;
23
23
  }
24
24
 
25
- export class Credentials extends Context.Service<
26
- Credentials,
27
- Effect.Effect<Config>
28
- >()("EasCredentials") {}
25
+ export class Credentials extends Context.Service<Credentials, Effect.Effect<Config>>()(
26
+ "EasCredentials",
27
+ ) {}
29
28
 
30
29
  const envConfig = EffectConfig.all({
31
30
  accessToken: EffectConfig.String("EXPO_TOKEN"),
package/src/errors.ts CHANGED
@@ -26,6 +26,7 @@ export {
26
26
  DEFAULT_ERRORS,
27
27
  API_ERRORS,
28
28
  } from "@distilled.cloud/core/errors";
29
+ import * as Category from "@distilled.cloud/core/category";
29
30
  import type {
30
31
  BadRequest,
31
32
  Conflict,
@@ -35,31 +36,23 @@ import type {
35
36
  NotFound,
36
37
  UnprocessableEntity,
37
38
  } from "@distilled.cloud/core/errors";
38
-
39
39
  import * as Schema from "effect/Schema";
40
- import * as Category from "@distilled.cloud/core/category";
41
40
 
42
41
  /**
43
42
  * Catch-all for EAS GraphQL errors that don't match any other tagged class.
44
43
  * Carries the raw `errors[]` envelope and a best-effort `code` / `message`.
45
44
  */
46
- export class UnknownEasError extends Schema.TaggedError<UnknownEasError>()(
47
- "UnknownEasError",
48
- {
49
- code: Schema.optional(Schema.String),
50
- message: Schema.optional(Schema.String),
51
- body: Schema.Unknown,
52
- },
53
- ).pipe(Category.withServerError) {}
45
+ export class UnknownEasError extends Schema.TaggedError<UnknownEasError>()("UnknownEasError", {
46
+ code: Schema.optional(Schema.String),
47
+ message: Schema.optional(Schema.String),
48
+ body: Schema.Unknown,
49
+ }).pipe(Category.withServerError) {}
54
50
 
55
51
  /** Schema parse error wrapper (response body did not match the operation schema). */
56
- export class EasParseError extends Schema.TaggedError<EasParseError>()(
57
- "EasParseError",
58
- {
59
- body: Schema.Unknown,
60
- cause: Schema.Unknown,
61
- },
62
- ).pipe(Category.withParseError) {}
52
+ export class EasParseError extends Schema.TaggedError<EasParseError>()("EasParseError", {
53
+ body: Schema.Unknown,
54
+ cause: Schema.Unknown,
55
+ }).pipe(Category.withParseError) {}
63
56
 
64
57
  /**
65
58
  * The installed eas-cli / API client is too old. Returned with errorCode
@@ -157,9 +150,7 @@ export class EasChannelAlreadyExists extends Schema.TaggedError<EasChannelAlread
157
150
  {
158
151
  message: Schema.String,
159
152
  },
160
- ).pipe(
161
- Category.withCategory(Category.ConflictError, Category.AlreadyExistsError),
162
- ) {}
153
+ ).pipe(Category.withCategory(Category.ConflictError, Category.AlreadyExistsError)) {}
163
154
 
164
155
  /**
165
156
  * The authenticated actor does not have permission to perform this operation
@@ -205,9 +196,7 @@ export class EasDevDomainNameTaken extends Schema.TaggedError<EasDevDomainNameTa
205
196
  {
206
197
  message: Schema.String,
207
198
  },
208
- ).pipe(
209
- Category.withCategory(Category.ConflictError, Category.AlreadyExistsError),
210
- ) {}
199
+ ).pipe(Category.withCategory(Category.ConflictError, Category.AlreadyExistsError)) {}
211
200
 
212
201
  /**
213
202
  * Map from EAS GraphQL `extensions.errorCode` → typed error class.
@@ -230,10 +219,8 @@ export const EAS_ERROR_CODE_MAP: Record<string, any> = {
230
219
  EAS_BUILD_FREE_TIER_IOS_LIMIT_EXCEEDED: EasBuildFreeTierLimitExceeded,
231
220
  EAS_BUILD_FREE_TIER_ANDROID_LIMIT_EXCEEDED: EasBuildFreeTierLimitExceeded,
232
221
  EAS_BUILD_TOO_MANY_PENDING_BUILDS: EasBuildTooManyPendingBuilds,
233
- EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER:
234
- EasBuildResourceClassNotAvailableInFreeTier,
235
- EAS_BUILD_LEGACY_RESOURCE_CLASS_NOT_AVAILABLE:
236
- EasBuildLegacyResourceClassNotAvailable,
222
+ EAS_BUILD_RESOURCE_CLASS_NOT_AVAILABLE_IN_FREE_TIER: EasBuildResourceClassNotAvailableInFreeTier,
223
+ EAS_BUILD_LEGACY_RESOURCE_CLASS_NOT_AVAILABLE: EasBuildLegacyResourceClassNotAvailable,
237
224
  CHANNEL_ALREADY_EXISTS: EasChannelAlreadyExists,
238
225
  UNAUTHORIZED_ERROR: EasUnauthorizedOperation,
239
226
  EXPERIENCE_NOT_FOUND: EasExperienceNotFound,
package/src/index.ts CHANGED
@@ -13,10 +13,6 @@
13
13
  export * from "./credentials.ts";
14
14
  export * from "./errors.ts";
15
15
  export * as T from "./traits.ts";
16
- export {
17
- ExpoGraphqlProtocol,
18
- type ExpoEasOpError,
19
- type ExpoEasOpContext,
20
- } from "./protocol.ts";
16
+ export { ExpoGraphqlProtocol, type ExpoEasOpError, type ExpoEasOpContext } from "./protocol.ts";
21
17
  export * as Retry from "./retry.ts";
22
18
  export * as Services from "./services/index.ts";
@@ -0,0 +1,362 @@
1
+ import * as ResponseValidation from "@distilled.cloud/core/response-validation";
2
+ import * as ConfigProvider from "effect/ConfigProvider";
3
+ import * as Duration from "effect/Duration";
4
+ import * as Effect from "effect/Effect";
5
+ import * as HttpClient from "effect/http/HttpClient";
6
+ import * as HttpClientRequest from "effect/http/HttpClientRequest";
7
+ import * as HttpClientResponse from "effect/http/HttpClientResponse";
8
+ import * as Layer from "effect/Layer";
9
+ import * as Redacted from "effect/Redacted";
10
+ import * as Result from "effect/Result";
11
+ import { describe, expect, test } from "vitest";
12
+ import { Credentials, CredentialsFromEnv } from "./credentials.ts";
13
+ import {
14
+ BadGateway,
15
+ BadRequest,
16
+ EasChannelAlreadyExists,
17
+ EasExperienceNotFound,
18
+ EasParseError,
19
+ EasUnauthorizedOperation,
20
+ InternalServerError,
21
+ ServiceUnavailable,
22
+ TooManyRequests,
23
+ Unauthorized,
24
+ UnknownEasError,
25
+ } from "./errors.ts";
26
+ import type { ExpoEasOpContext } from "./protocol.ts";
27
+ import * as Retry from "./retry.ts";
28
+ import { accessTokenDeleteAccessToken, accessTokenSetAccessTokenRevoked } from "./services/eas.ts";
29
+
30
+ interface Reply {
31
+ readonly status?: number;
32
+ readonly body?: string;
33
+ readonly headers?: Record<string, string>;
34
+ }
35
+
36
+ const credentialsLayer = (apiBaseUrl: string, token = "expo-token") =>
37
+ Layer.succeed(Credentials, Effect.succeed({ accessToken: Redacted.make(token), apiBaseUrl }));
38
+
39
+ const prod = credentialsLayer("https://api.expo.dev");
40
+
41
+ /** A fake HttpClient that records each request (as a web Request) and answers with `reply`. */
42
+ const fakeHttp = (reply: Reply) => {
43
+ const requests: Array<Request> = [];
44
+ const http = Layer.succeed(
45
+ HttpClient.HttpClient,
46
+ HttpClient.make((request) =>
47
+ Effect.sync(() => {
48
+ requests.push(Result.getOrThrow(HttpClientRequest.toWebResult(request)));
49
+ return HttpClientResponse.fromWeb(
50
+ request,
51
+ new Response(reply.body ?? "", {
52
+ status: reply.status ?? 200,
53
+ headers: reply.headers,
54
+ }),
55
+ );
56
+ }),
57
+ ),
58
+ );
59
+ return { requests, http };
60
+ };
61
+
62
+ const run = <A, E>(
63
+ operation: Effect.Effect<A, E, ExpoEasOpContext>,
64
+ reply: Reply,
65
+ creds: Layer.Layer<Credentials> = prod,
66
+ ) => {
67
+ const { requests, http } = fakeHttp(reply);
68
+ const promise = Effect.runPromise(
69
+ operation.pipe(Retry.none, Effect.provide(Layer.mergeAll(http, creds))),
70
+ );
71
+ return { requests, promise };
72
+ };
73
+
74
+ const deleteToken = () => accessTokenDeleteAccessToken({ id: "tok-1" });
75
+
76
+ const succeed = (reply: Reply) => run(deleteToken(), reply).promise;
77
+ const failWith = (reply: Reply, operation = deleteToken()) =>
78
+ run(Effect.flip(operation), reply).promise;
79
+
80
+ const data = (id: unknown) =>
81
+ JSON.stringify({ data: { accessToken: { deleteAccessToken: { id } } } });
82
+
83
+ const graphqlErrors = (
84
+ errors: ReadonlyArray<{ message: string; extensions?: Record<string, string> }>,
85
+ extra: Record<string, unknown> = {},
86
+ ) => JSON.stringify({ data: null, errors, ...extra });
87
+
88
+ describe("request envelope", () => {
89
+ test("POSTs { query, operationName, variables } to <base>/graphql with a Bearer token", async () => {
90
+ const { requests, promise } = run(deleteToken(), { body: data("tok-1") });
91
+ await promise;
92
+ expect(requests).toHaveLength(1);
93
+ const request = requests[0]!;
94
+ expect(request.method).toBe("POST");
95
+ expect(request.url).toBe("https://api.expo.dev/graphql");
96
+ expect(request.headers.get("authorization")).toBe("Bearer expo-token");
97
+ expect(request.headers.get("accept")).toBe("application/json");
98
+ expect(request.headers.get("content-type")).toContain("application/json");
99
+ const body = await request.json();
100
+ expect(body).toEqual({
101
+ query: expect.stringContaining("mutation accessTokenDeleteAccessToken($id: ID!)"),
102
+ operationName: "accessTokenDeleteAccessToken",
103
+ variables: { id: "tok-1" },
104
+ });
105
+ });
106
+
107
+ test("input members are variables verbatim: undefined dropped, null kept", async () => {
108
+ const omitted = run(accessTokenSetAccessTokenRevoked({ id: "tok-1", revoked: undefined }), {
109
+ body: JSON.stringify({ data: { accessToken: { setAccessTokenRevoked: null } } }),
110
+ });
111
+ await omitted.promise;
112
+ expect((await omitted.requests[0]!.json()).variables).toEqual({ id: "tok-1" });
113
+
114
+ const nulled = run(accessTokenSetAccessTokenRevoked({ id: "tok-1", revoked: null }), {
115
+ body: JSON.stringify({ data: { accessToken: { setAccessTokenRevoked: null } } }),
116
+ });
117
+ await nulled.promise;
118
+ expect((await nulled.requests[0]!.json()).variables).toEqual({ id: "tok-1", revoked: null });
119
+ });
120
+
121
+ test("the staging base URL is honoured", async () => {
122
+ const { requests, promise } = run(
123
+ deleteToken(),
124
+ { body: data("tok-1") },
125
+ credentialsLayer("https://staging-api.expo.dev", "staging-token"),
126
+ );
127
+ await promise;
128
+ expect(requests[0]!.url).toBe("https://staging-api.expo.dev/graphql");
129
+ expect(requests[0]!.headers.get("authorization")).toBe("Bearer staging-token");
130
+ });
131
+
132
+ const fromEnv = (env: Record<string, string>) =>
133
+ Layer.mergeAll(
134
+ CredentialsFromEnv,
135
+ Layer.succeed(ConfigProvider.ConfigProvider, ConfigProvider.fromUnknown(env)),
136
+ );
137
+
138
+ test("CredentialsFromEnv targets production by default and staging via EXPO_API_URL", async () => {
139
+ const production = run(
140
+ deleteToken(),
141
+ { body: data("tok-1") },
142
+ fromEnv({ EXPO_TOKEN: "env-token" }),
143
+ );
144
+ await production.promise;
145
+ expect(production.requests[0]!.url).toBe("https://api.expo.dev/graphql");
146
+ expect(production.requests[0]!.headers.get("authorization")).toBe("Bearer env-token");
147
+
148
+ const staging = run(
149
+ deleteToken(),
150
+ { body: data("tok-1") },
151
+ fromEnv({ EXPO_TOKEN: "env-token", EXPO_API_URL: "https://staging-api.expo.dev" }),
152
+ );
153
+ await staging.promise;
154
+ expect(staging.requests[0]!.url).toBe("https://staging-api.expo.dev/graphql");
155
+ });
156
+ });
157
+
158
+ describe("data envelope", () => {
159
+ test("returns the value at data.<responsePath>", async () => {
160
+ expect(await succeed({ body: data("tok-1") })).toEqual({ id: "tok-1" });
161
+ });
162
+
163
+ test("a missing or null path segment yields null", async () => {
164
+ expect(await succeed({ body: JSON.stringify({ data: { accessToken: null } }) })).toBeNull();
165
+ expect(await succeed({ body: JSON.stringify({ data: null }) })).toBeNull();
166
+ });
167
+
168
+ test("an empty errors array is not a failure", async () => {
169
+ const body = JSON.stringify({
170
+ data: { accessToken: { deleteAccessToken: { id: "tok-1" } } },
171
+ errors: [],
172
+ });
173
+ expect(await succeed({ body })).toEqual({ id: "tok-1" });
174
+ });
175
+ });
176
+
177
+ describe("GraphQL errors", () => {
178
+ test("errors[] inside HTTP 200 map extensions.errorCode to the typed class", async () => {
179
+ const error = await failWith({
180
+ body: graphqlErrors([
181
+ { message: "Channel already exists", extensions: { errorCode: "CHANNEL_ALREADY_EXISTS" } },
182
+ ]),
183
+ });
184
+ expect(error).toBeInstanceOf(EasChannelAlreadyExists);
185
+ expect(error).toMatchObject({ message: "Channel already exists" });
186
+ });
187
+
188
+ test("extensions.code is the fallback code field", async () => {
189
+ const error = await failWith({
190
+ body: graphqlErrors([{ message: "Not allowed", extensions: { code: "UNAUTHORIZED_ERROR" } }]),
191
+ });
192
+ expect(error).toBeInstanceOf(EasUnauthorizedOperation);
193
+ });
194
+
195
+ test("only the first error is used", async () => {
196
+ const error = await failWith({
197
+ body: graphqlErrors([
198
+ { message: "missing", extensions: { errorCode: "EXPERIENCE_NOT_FOUND" } },
199
+ { message: "taken", extensions: { errorCode: "CHANNEL_ALREADY_EXISTS" } },
200
+ ]),
201
+ });
202
+ expect(error).toBeInstanceOf(EasExperienceNotFound);
203
+ expect(error).toMatchObject({ message: "missing" });
204
+ });
205
+
206
+ test("errors win over partial data", async () => {
207
+ const error = await failWith({
208
+ body: JSON.stringify({
209
+ data: { accessToken: { deleteAccessToken: { id: "tok-1" } } },
210
+ errors: [{ message: "partial", extensions: { errorCode: "CHANNEL_ALREADY_EXISTS" } }],
211
+ }),
212
+ });
213
+ expect(error).toBeInstanceOf(EasChannelAlreadyExists);
214
+ });
215
+
216
+ test("an unknown code inside HTTP 200 is UnknownEasError carrying code, message and body", async () => {
217
+ const body = graphqlErrors([
218
+ { message: "Something new", extensions: { errorCode: "BRAND_NEW_CODE" } },
219
+ ]);
220
+ const error = await failWith({ body });
221
+ expect(error).toBeInstanceOf(UnknownEasError);
222
+ expect(error).toMatchObject({
223
+ code: "BRAND_NEW_CODE",
224
+ message: "Something new",
225
+ body: JSON.parse(body),
226
+ });
227
+ });
228
+
229
+ test("an error without extensions inside HTTP 200 is UnknownEasError without a code", async () => {
230
+ const error = await failWith({
231
+ body: graphqlErrors([{ message: 'Cannot query field "nope" on type "Query".' }]),
232
+ });
233
+ expect(error).toBeInstanceOf(UnknownEasError);
234
+ expect(error).toMatchObject({ message: 'Cannot query field "nope" on type "Query".' });
235
+ expect((error as UnknownEasError).code).toBeUndefined();
236
+ });
237
+
238
+ test("an unknown code under an error status uses the HTTP status class", async () => {
239
+ const error = await failWith({
240
+ status: 400,
241
+ body: graphqlErrors([
242
+ { message: "Variable $id is required", extensions: { code: "GRAPHQL_VALIDATION_FAILED" } },
243
+ ]),
244
+ });
245
+ expect(error).toBeInstanceOf(BadRequest);
246
+ expect(error).toMatchObject({ message: "Variable $id is required" });
247
+ });
248
+
249
+ test("a typed code still wins under an error status", async () => {
250
+ const error = await failWith({
251
+ status: 403,
252
+ body: graphqlErrors([
253
+ { message: "Not allowed", extensions: { errorCode: "UNAUTHORIZED_ERROR" } },
254
+ ]),
255
+ });
256
+ expect(error).toBeInstanceOf(EasUnauthorizedOperation);
257
+ });
258
+ });
259
+
260
+ describe("HTTP-level failures", () => {
261
+ test("a REST-ish { message } body uses the status class", async () => {
262
+ const error = await failWith({
263
+ status: 401,
264
+ body: JSON.stringify({ message: "Invalid access token" }),
265
+ });
266
+ expect(error).toBeInstanceOf(Unauthorized);
267
+ expect(error).toMatchObject({ message: "Invalid access token" });
268
+ });
269
+
270
+ test("429 carries the Retry-After hint", async () => {
271
+ const error = await failWith({
272
+ status: 429,
273
+ body: JSON.stringify({ message: "Too many requests" }),
274
+ headers: { "retry-after": "5" },
275
+ });
276
+ expect(error).toBeInstanceOf(TooManyRequests);
277
+ expect(Duration.toSeconds((error as TooManyRequests).retryAfter!)).toBe(5);
278
+ });
279
+
280
+ test("an empty JSON object under 500 is InternalServerError", async () => {
281
+ const error = await failWith({ status: 500, body: "{}" });
282
+ expect(error).toBeInstanceOf(InternalServerError);
283
+ });
284
+
285
+ test("a REST-ish body under an unmapped status is UnknownEasError", async () => {
286
+ const error = await failWith({
287
+ status: 418,
288
+ body: JSON.stringify({ message: "teapot", code: "TEAPOT" }),
289
+ });
290
+ expect(error).toBeInstanceOf(UnknownEasError);
291
+ expect(error).toMatchObject({ code: "TEAPOT", message: "teapot" });
292
+ });
293
+ });
294
+
295
+ describe("non-JSON HTTP-level failures", () => {
296
+ test("an HTML 502 proxy page is BadGateway with the trimmed body as message", async () => {
297
+ const html = "<html><body><h1>502 Bad Gateway</h1></body></html>";
298
+ const error = await failWith({
299
+ status: 502,
300
+ body: `\n ${html}\n`,
301
+ headers: { "content-type": "text/html" },
302
+ });
303
+ expect(error).toBeInstanceOf(BadGateway);
304
+ expect(error).toMatchObject({ message: html });
305
+ });
306
+
307
+ test("a plain-text 500 is InternalServerError", async () => {
308
+ const error = await failWith({ status: 500, body: "Internal Server Error" });
309
+ expect(error).toBeInstanceOf(InternalServerError);
310
+ expect(error).toMatchObject({ message: "Internal Server Error" });
311
+ });
312
+
313
+ test("a plain-text 429 is TooManyRequests carrying the Retry-After hint", async () => {
314
+ const error = await failWith({
315
+ status: 429,
316
+ body: "Too Many Requests",
317
+ headers: { "content-type": "text/plain", "retry-after": "7" },
318
+ });
319
+ expect(error).toBeInstanceOf(TooManyRequests);
320
+ expect(error).toMatchObject({ message: "Too Many Requests" });
321
+ expect(Duration.toSeconds((error as TooManyRequests).retryAfter!)).toBe(7);
322
+ });
323
+
324
+ test("a plain-text 401 is Unauthorized", async () => {
325
+ const error = await failWith({ status: 401, body: "Unauthorized\n" });
326
+ expect(error).toBeInstanceOf(Unauthorized);
327
+ expect(error).toMatchObject({ message: "Unauthorized" });
328
+ });
329
+
330
+ test("an empty error body uses the status class with an HTTP <status> message", async () => {
331
+ const error = await failWith({ status: 503, body: "" });
332
+ expect(error).toBeInstanceOf(ServiceUnavailable);
333
+ expect(error).toMatchObject({ message: "HTTP 503" });
334
+ });
335
+
336
+ test("a non-JSON body under an unmapped status is UnknownEasError carrying the body", async () => {
337
+ const error = await failWith({ status: 418, body: "I'm a teapot" });
338
+ expect(error).toBeInstanceOf(UnknownEasError);
339
+ expect(error).toMatchObject({ body: "I'm a teapot" });
340
+ });
341
+ });
342
+
343
+ describe("response validation", () => {
344
+ const strict = deleteToken().pipe(Effect.provide(ResponseValidation.strict));
345
+
346
+ test("lenient mode returns a mismatched payload as read", async () => {
347
+ expect((await succeed({ body: data(42) })) as unknown).toEqual({ id: 42 });
348
+ });
349
+
350
+ test("strict mode fails a mismatched payload with EasParseError", async () => {
351
+ expect(await failWith({ body: data(42) }, strict)).toBeInstanceOf(EasParseError);
352
+ });
353
+
354
+ test("strict mode passes a matching payload", async () => {
355
+ expect(await run(strict, { body: data("tok-1") }).promise).toEqual({ id: "tok-1" });
356
+ });
357
+
358
+ test("a non-JSON 2xx body: lenient returns the text, strict fails", async () => {
359
+ expect((await succeed({ body: "not json" })) as unknown).toBe("not json");
360
+ expect(await failWith({ body: "not json" }, strict)).toBeInstanceOf(EasParseError);
361
+ });
362
+ });