@apifuse/connector-sdk 2.0.0-beta.1

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.
Files changed (67) hide show
  1. package/README.md +44 -0
  2. package/bin/apifuse-check.ts +408 -0
  3. package/bin/apifuse-dev.ts +222 -0
  4. package/bin/apifuse-init.ts +390 -0
  5. package/bin/apifuse-perf.ts +1101 -0
  6. package/bin/apifuse-record.ts +446 -0
  7. package/bin/apifuse-test.ts +688 -0
  8. package/bin/apifuse.ts +51 -0
  9. package/package.json +64 -0
  10. package/src/__tests__/auth.test.ts +396 -0
  11. package/src/__tests__/browser-auth.test.ts +180 -0
  12. package/src/__tests__/browser.test.ts +632 -0
  13. package/src/__tests__/connectors-yaml.test.ts +135 -0
  14. package/src/__tests__/define.test.ts +225 -0
  15. package/src/__tests__/errors.test.ts +69 -0
  16. package/src/__tests__/executor.test.ts +214 -0
  17. package/src/__tests__/http.test.ts +238 -0
  18. package/src/__tests__/insights.test.ts +210 -0
  19. package/src/__tests__/instrumentation.test.ts +290 -0
  20. package/src/__tests__/otlp.test.ts +141 -0
  21. package/src/__tests__/perf.test.ts +60 -0
  22. package/src/__tests__/proxy.test.ts +359 -0
  23. package/src/__tests__/recipes.test.ts +36 -0
  24. package/src/__tests__/serve.test.ts +233 -0
  25. package/src/__tests__/session.test.ts +231 -0
  26. package/src/__tests__/state.test.ts +100 -0
  27. package/src/__tests__/stealth.test.ts +57 -0
  28. package/src/__tests__/testing.test.ts +97 -0
  29. package/src/__tests__/tls.test.ts +345 -0
  30. package/src/__tests__/types.test.ts +142 -0
  31. package/src/__tests__/utils.test.ts +62 -0
  32. package/src/__tests__/waterfall.test.ts +270 -0
  33. package/src/config/connectors-yaml.ts +373 -0
  34. package/src/config/loader.ts +122 -0
  35. package/src/define.ts +137 -0
  36. package/src/dev.ts +38 -0
  37. package/src/errors.ts +68 -0
  38. package/src/index.test.ts +1 -0
  39. package/src/index.ts +100 -0
  40. package/src/protocol.ts +183 -0
  41. package/src/recipes/gov-api.ts +97 -0
  42. package/src/recipes/rest-api.ts +152 -0
  43. package/src/runtime/auth.ts +245 -0
  44. package/src/runtime/browser.ts +724 -0
  45. package/src/runtime/connector.ts +20 -0
  46. package/src/runtime/executor.ts +51 -0
  47. package/src/runtime/http.ts +248 -0
  48. package/src/runtime/insights.ts +456 -0
  49. package/src/runtime/instrumentation.ts +424 -0
  50. package/src/runtime/otlp.ts +171 -0
  51. package/src/runtime/perf.ts +73 -0
  52. package/src/runtime/session.ts +573 -0
  53. package/src/runtime/state.ts +124 -0
  54. package/src/runtime/tls.ts +410 -0
  55. package/src/runtime/trace.ts +261 -0
  56. package/src/runtime/waterfall.ts +245 -0
  57. package/src/serve.ts +665 -0
  58. package/src/stealth/profiles.ts +391 -0
  59. package/src/testing/helpers.ts +144 -0
  60. package/src/testing/index.ts +2 -0
  61. package/src/testing/run.ts +88 -0
  62. package/src/types/playwright-stealth.d.ts +9 -0
  63. package/src/types.ts +243 -0
  64. package/src/utils/date.ts +163 -0
  65. package/src/utils/parse.ts +66 -0
  66. package/src/utils/text.ts +20 -0
  67. package/src/utils/transform.ts +62 -0
@@ -0,0 +1,135 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { rm } from "node:fs/promises";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+
7
+ import {
8
+ loadConnectorsYaml,
9
+ parseConnectorsYaml,
10
+ resolveConnectorConfig,
11
+ } from "../config/connectors-yaml";
12
+
13
+ const exampleYaml = `
14
+ defaults:
15
+ container:
16
+ image: auto
17
+ memory: 256Mi
18
+ cpu: 250m
19
+ timeout: 30s
20
+ restart-policy: on-failure
21
+ max-restarts: 3
22
+ security:
23
+ read-only-rootfs: true
24
+ no-new-privileges: true
25
+
26
+ connectors:
27
+ coingecko: {}
28
+ catchtable:
29
+ container:
30
+ memory: 1Gi
31
+ cpu: 1000m
32
+ replicas: 2
33
+ security:
34
+ egress: ['ct-api.catchtable.co.kr']
35
+ env: ['CATCHTABLE_PROXY_URL']
36
+ bank-login:
37
+ runtime: browser
38
+ container:
39
+ memory: 2Gi
40
+ cdp:
41
+ pool-size: 2..10
42
+ page-timeout: 120s
43
+ `;
44
+
45
+ const tempDirs: string[] = [];
46
+
47
+ afterEach(async () => {
48
+ await Promise.all(
49
+ tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })),
50
+ );
51
+ });
52
+
53
+ describe("connectors.yaml", () => {
54
+ it("parses the documented example and normalizes pool-size", () => {
55
+ const parsed = parseConnectorsYaml(exampleYaml);
56
+
57
+ expect(parsed.defaults?.container?.memory).toBe("256Mi");
58
+ expect(parsed.connectors.coingecko).toEqual({});
59
+ expect(parsed.connectors["bank-login"].cdp?.["pool-size"]).toEqual({
60
+ min: 2,
61
+ max: 10,
62
+ });
63
+ });
64
+
65
+ it("resolves defaults for empty connector config", () => {
66
+ const parsed = parseConnectorsYaml(exampleYaml);
67
+ const resolved = resolveConnectorConfig(parsed, "coingecko");
68
+
69
+ expect(resolved.runtime).toBe("standard");
70
+ expect(resolved.replicas).toBe(1);
71
+ expect(resolved.container).toEqual({
72
+ image: "auto",
73
+ memory: "256Mi",
74
+ cpu: "250m",
75
+ timeout: "30s",
76
+ "restart-policy": "on-failure",
77
+ "max-restarts": 3,
78
+ });
79
+ expect(resolved.security).toEqual({
80
+ "read-only-rootfs": true,
81
+ "no-new-privileges": true,
82
+ });
83
+ });
84
+
85
+ it("merges connector overrides with defaults", () => {
86
+ const parsed = parseConnectorsYaml(exampleYaml);
87
+ const resolved = resolveConnectorConfig(parsed, "catchtable");
88
+
89
+ expect(resolved.replicas).toBe(2);
90
+ expect(resolved.container.memory).toBe("1Gi");
91
+ expect(resolved.container.cpu).toBe("1000m");
92
+ expect(resolved.container.timeout).toBe("30s");
93
+ expect(resolved.security.egress).toEqual(["ct-api.catchtable.co.kr"]);
94
+ expect(resolved.security.env).toEqual(["CATCHTABLE_PROXY_URL"]);
95
+ expect(resolved.security["read-only-rootfs"]).toBe(true);
96
+ });
97
+
98
+ it("applies browser cdp defaults while preserving parsed pool range", () => {
99
+ const parsed = parseConnectorsYaml(exampleYaml);
100
+ const resolved = resolveConnectorConfig(parsed, "bank-login");
101
+
102
+ expect(resolved.runtime).toBe("browser");
103
+ expect(resolved.container.memory).toBe("2Gi");
104
+ expect(resolved.cdp).toEqual({
105
+ "pool-size": { min: 2, max: 10 },
106
+ "page-timeout": "120s",
107
+ "pages-per-instance": 4,
108
+ "idle-timeout": "5m",
109
+ });
110
+ });
111
+
112
+ it("loads connectors yaml from disk", async () => {
113
+ const dir = mkdtempSync(path.join(tmpdir(), "connectors-yaml-"));
114
+ tempDirs.push(dir);
115
+ const filePath = path.join(dir, "connectors.yaml");
116
+
117
+ await Bun.write(filePath, exampleYaml);
118
+
119
+ const loaded = await loadConnectorsYaml(filePath);
120
+
121
+ expect(loaded.connectors.catchtable.replicas).toBe(2);
122
+ });
123
+
124
+ it("rejects invalid pool-size ranges", () => {
125
+ expect(() =>
126
+ parseConnectorsYaml(`
127
+ connectors:
128
+ bank-login:
129
+ runtime: browser
130
+ cdp:
131
+ pool-size: 10..2
132
+ `),
133
+ ).toThrow("Invalid connectors.yaml configuration");
134
+ });
135
+ });
@@ -0,0 +1,225 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { z } from "zod";
3
+
4
+ import { defineConnector } from "../define";
5
+ import { ConnectorError, ValidationError } from "../errors";
6
+ import type { ConnectorContext } from "../types";
7
+
8
+ const InputSchema = z.object({ id: z.string() });
9
+ const OutputSchema = z.object({ name: z.string(), price: z.number() });
10
+
11
+ const validConfig = {
12
+ id: "coingecko-prices",
13
+ version: "1.0.0",
14
+ runtime: "standard" as const,
15
+ meta: {
16
+ displayName: "CoinGecko Prices",
17
+ description: "Simple finance connector",
18
+ category: "finance",
19
+ tags: ["prices"],
20
+ },
21
+ operations: {
22
+ prices: {
23
+ input: InputSchema,
24
+ output: OutputSchema,
25
+ handler: async (_ctx: ConnectorContext, input: unknown) => {
26
+ const parsed = InputSchema.parse(input);
27
+
28
+ return {
29
+ name: parsed.id,
30
+ price: 50_000,
31
+ };
32
+ },
33
+ fixtures: {
34
+ request: { id: "bitcoin" },
35
+ response: { name: "Bitcoin", price: 50_000 },
36
+ },
37
+ },
38
+ },
39
+ };
40
+
41
+ describe("defineConnector", () => {
42
+ it("returns connector with top-level identity fields accessible", () => {
43
+ const connector = defineConnector(validConfig);
44
+
45
+ expect(connector.id).toBe("coingecko-prices");
46
+ expect(connector.version).toBe("1.0.0");
47
+ expect(connector.runtime).toBe("standard");
48
+ expect(connector.meta.displayName).toBe("CoinGecko Prices");
49
+ });
50
+
51
+ it("preserves operation definitions", async () => {
52
+ const connector = defineConnector(validConfig);
53
+ await expect(
54
+ connector.operations.prices.handler?.({} as never, { id: "bitcoin" }),
55
+ ).resolves.toEqual({ name: "bitcoin", price: 50_000 });
56
+ });
57
+
58
+ it("throws ConnectorError for invalid id format - uppercase", () => {
59
+ expect(() =>
60
+ defineConnector({
61
+ ...validConfig,
62
+ id: "CoinGecko",
63
+ }),
64
+ ).toThrow(ConnectorError);
65
+ });
66
+
67
+ it("throws ConnectorError for invalid id format - single word", () => {
68
+ expect(() =>
69
+ defineConnector({ ...validConfig, id: "coingecko" }),
70
+ ).not.toThrow();
71
+ });
72
+
73
+ it("throws ConnectorError for invalid id format - spaces", () => {
74
+ expect(() =>
75
+ defineConnector({
76
+ ...validConfig,
77
+ id: "coin gecko prices",
78
+ }),
79
+ ).toThrow(ConnectorError);
80
+ });
81
+
82
+ it("throws ConnectorError for invalid id format - underscore", () => {
83
+ expect(() =>
84
+ defineConnector({
85
+ ...validConfig,
86
+ id: "coingecko_prices",
87
+ }),
88
+ ).toThrow(ConnectorError);
89
+ });
90
+
91
+ it("throws ValidationError when operation fixture.request does not match input schema", () => {
92
+ const badConfig = {
93
+ ...validConfig,
94
+ operations: {
95
+ ...validConfig.operations,
96
+ prices: {
97
+ ...validConfig.operations.prices,
98
+ fixtures: {
99
+ request: { wrong_field: "x" } as unknown as z.infer<
100
+ typeof InputSchema
101
+ >,
102
+ response: validConfig.operations.prices.fixtures.response,
103
+ },
104
+ },
105
+ },
106
+ };
107
+
108
+ expect(() => defineConnector(badConfig)).toThrow(ValidationError);
109
+ });
110
+
111
+ it("throws ValidationError when operation fixture.response does not match output schema", () => {
112
+ const badConfig = {
113
+ ...validConfig,
114
+ operations: {
115
+ ...validConfig.operations,
116
+ prices: {
117
+ ...validConfig.operations.prices,
118
+ fixtures: {
119
+ request: validConfig.operations.prices.fixtures.request,
120
+ response: { wrong: true } as unknown as z.infer<
121
+ typeof OutputSchema
122
+ >,
123
+ },
124
+ },
125
+ },
126
+ };
127
+
128
+ expect(() => defineConnector(badConfig)).toThrow(ValidationError);
129
+ });
130
+
131
+ it("ValidationError includes zodError for actionable debugging", () => {
132
+ const badConfig = {
133
+ ...validConfig,
134
+ operations: {
135
+ ...validConfig.operations,
136
+ prices: {
137
+ ...validConfig.operations.prices,
138
+ fixtures: {
139
+ request: { wrong_field: "x" } as unknown as z.infer<
140
+ typeof InputSchema
141
+ >,
142
+ response: validConfig.operations.prices.fixtures.response,
143
+ },
144
+ },
145
+ },
146
+ };
147
+
148
+ try {
149
+ defineConnector(badConfig);
150
+ } catch (error) {
151
+ expect(error instanceof ValidationError).toBe(true);
152
+ expect((error as ValidationError).zodError).toBeDefined();
153
+ }
154
+ });
155
+
156
+ it("ConnectorError has fix hint for invalid id", () => {
157
+ try {
158
+ defineConnector({ ...validConfig, id: "BAD_ID" });
159
+ } catch (error) {
160
+ expect(error instanceof ConnectorError).toBe(true);
161
+ expect((error as ConnectorError).fix).toBeDefined();
162
+ }
163
+ });
164
+
165
+ it("throws ConnectorError when no operations defined", () => {
166
+ expect(() => defineConnector({ ...validConfig, operations: {} })).toThrow(
167
+ ConnectorError,
168
+ );
169
+ });
170
+
171
+ it("works without operation fixtures", () => {
172
+ const noFixturesConfig = {
173
+ ...validConfig,
174
+ operations: {
175
+ ...validConfig.operations,
176
+ prices: {
177
+ ...validConfig.operations.prices,
178
+ fixtures: undefined,
179
+ },
180
+ },
181
+ };
182
+
183
+ const connector = defineConnector(noFixturesConfig);
184
+
185
+ expect(connector.id).toBe("coingecko-prices");
186
+ expect(connector.operations.prices.fixtures).toBeUndefined();
187
+ });
188
+
189
+ it("requires browser config when runtime is browser", () => {
190
+ expect(() =>
191
+ defineConnector({
192
+ ...validConfig,
193
+ runtime: "browser",
194
+ }),
195
+ ).toThrow(ConnectorError);
196
+ });
197
+
198
+ it("rejects browser config when runtime is not browser", () => {
199
+ expect(() =>
200
+ defineConnector({
201
+ ...validConfig,
202
+ browser: { engine: "nodriver" },
203
+ }),
204
+ ).toThrow(ConnectorError);
205
+ });
206
+
207
+ it("keeps operation schema inference usable for input parsing", () => {
208
+ const connector = defineConnector(validConfig);
209
+ const parsed = connector.operations.prices.input?.safeParse({
210
+ id: "bitcoin",
211
+ });
212
+
213
+ expect(parsed?.success).toBe(true);
214
+ });
215
+
216
+ it("keeps operation schema inference usable for output parsing", () => {
217
+ const connector = defineConnector(validConfig);
218
+ const parsed = connector.operations.prices.output?.safeParse({
219
+ name: "Bitcoin",
220
+ price: 50_000,
221
+ });
222
+
223
+ expect(parsed?.success).toBe(true);
224
+ });
225
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ AuthError,
4
+ ConnectorError,
5
+ SDKError,
6
+ TransportError,
7
+ ValidationError,
8
+ } from "../errors";
9
+
10
+ describe("ConnectorError", () => {
11
+ it("should include fix hint", () => {
12
+ const err = new ConnectorError("Something broke", { fix: "Try again" });
13
+ expect(err.fix).toBe("Try again");
14
+ expect(err.message).toBe("Something broke");
15
+ expect(err.name).toBe("ConnectorError");
16
+ });
17
+
18
+ it("should include code", () => {
19
+ const err = new ConnectorError("Error", { code: "E001" });
20
+ expect(err.code).toBe("E001");
21
+ });
22
+
23
+ it("should be instanceof Error", () => {
24
+ const err = new ConnectorError("test");
25
+ expect(err instanceof Error).toBe(true);
26
+ expect(err instanceof ConnectorError).toBe(true);
27
+ });
28
+ });
29
+
30
+ describe("AuthError", () => {
31
+ it("should be instanceof ConnectorError", () => {
32
+ const err = new AuthError("Unauthorized", {
33
+ code: "refresh_failed",
34
+ fix: "Re-authenticate",
35
+ });
36
+ expect(err instanceof ConnectorError).toBe(true);
37
+ expect(err instanceof AuthError).toBe(true);
38
+ expect(err.name).toBe("AuthError");
39
+ expect(err.code).toBe("refresh_failed");
40
+ });
41
+ });
42
+
43
+ describe("ValidationError", () => {
44
+ it("should store zodError", () => {
45
+ const zodErr = { issues: [{ path: ["id"], message: "Required" }] };
46
+ const err = new ValidationError("Invalid input", {
47
+ zodError: zodErr,
48
+ fix: "Check id field",
49
+ });
50
+ expect(err.zodError).toEqual(zodErr);
51
+ expect(err instanceof ConnectorError).toBe(true);
52
+ });
53
+ });
54
+
55
+ describe("TransportError", () => {
56
+ it("should store HTTP status", () => {
57
+ const err = new TransportError("Connection failed", { status: 502 });
58
+ expect(err.status).toBe(502);
59
+ expect(err instanceof ConnectorError).toBe(true);
60
+ });
61
+ });
62
+
63
+ describe("SDKError", () => {
64
+ it("should be instanceof ConnectorError", () => {
65
+ const err = new SDKError("Internal SDK error");
66
+ expect(err instanceof ConnectorError).toBe(true);
67
+ expect(err.name).toBe("SDKError");
68
+ });
69
+ });
@@ -0,0 +1,214 @@
1
+ import { describe, expect, it, mock } from "bun:test";
2
+
3
+ import { z } from "../../node_modules/zod";
4
+
5
+ import { TransportError } from "../errors";
6
+ import { executeOperation } from "../runtime/executor";
7
+ import type { ConnectorContext, ConnectorDefinition } from "../types";
8
+
9
+ function createMockCtx(fetchResponse: unknown, status = 200): ConnectorContext {
10
+ return {
11
+ tls: {
12
+ fetch: mock(async (_url: string, _opts?: unknown) => ({
13
+ status,
14
+ ok: status >= 200 && status < 300,
15
+ headers: {},
16
+ rawHeaders: [],
17
+ body: "",
18
+ cookies: {
19
+ get: () => undefined,
20
+ getAll: () => ({}),
21
+ toString: () => "",
22
+ },
23
+ json: async <T>() => fetchResponse as T,
24
+ })),
25
+ createSession: mock(() => ({
26
+ fetch: async () => ({
27
+ status,
28
+ ok: status >= 200 && status < 300,
29
+ headers: {},
30
+ rawHeaders: [] as [string, string][],
31
+ body: "",
32
+ cookies: {
33
+ get: () => undefined,
34
+ getAll: () => ({}),
35
+ toString: () => "",
36
+ },
37
+ json: async <T>() => ({}) as T,
38
+ }),
39
+ close: () => {},
40
+ })),
41
+ },
42
+ http: {} as ConnectorContext["http"],
43
+ browser: {} as ConnectorContext["browser"],
44
+ session: {
45
+ get: mock(async () => null),
46
+ set: mock(async () => {}),
47
+ delete: mock(async () => {}),
48
+ },
49
+ state: {} as ConnectorContext["state"],
50
+ trace: {
51
+ span: async <T>(_name: string, fn: () => Promise<T>) => fn(),
52
+ },
53
+ auth: {
54
+ requestField: mock(async () => ""),
55
+ },
56
+ };
57
+ }
58
+
59
+ function createMockConnector(options?: {
60
+ handler?: ConnectorDefinition["operations"][string]["handler"];
61
+ auth?: ConnectorDefinition["auth"];
62
+ }): ConnectorDefinition {
63
+ return {
64
+ id: "test-connector",
65
+ version: "1.0.0",
66
+ runtime: "standard",
67
+ auth: options?.auth,
68
+ meta: {
69
+ displayName: "Test Connector",
70
+ category: "test",
71
+ },
72
+ operations: {
73
+ search: {
74
+ description: "Search",
75
+ input: z.object({ query: z.string() }),
76
+ output: z.object({ results: z.array(z.string()) }),
77
+ handler:
78
+ options?.handler ??
79
+ (async (_ctx: ConnectorContext, input: unknown) => {
80
+ const parsed = z.object({ query: z.string() }).parse(input);
81
+
82
+ return {
83
+ results: [parsed.query],
84
+ };
85
+ }),
86
+ },
87
+ },
88
+ };
89
+ }
90
+
91
+ describe("executeOperation", () => {
92
+ it("runs the handler and validates output", async () => {
93
+ const ctx = createMockCtx({});
94
+ const connector = createMockConnector();
95
+
96
+ const result = await executeOperation(connector, "search", ctx, {
97
+ query: "test",
98
+ });
99
+
100
+ expect(result).toEqual({ results: ["test"] });
101
+ });
102
+
103
+ it("passes through handler output parsing", async () => {
104
+ const ctx = createMockCtx({});
105
+ const connector = createMockConnector({
106
+ handler: async () => ({ results: ["result1", "result2"] }),
107
+ });
108
+
109
+ const result = await executeOperation(connector, "search", ctx, {
110
+ query: "test",
111
+ });
112
+
113
+ expect(result).toEqual({ results: ["result1", "result2"] });
114
+ });
115
+
116
+ it("throws ConnectorError when operation not found", async () => {
117
+ const ctx = createMockCtx({});
118
+ const connector = createMockConnector();
119
+
120
+ await expect(
121
+ executeOperation(connector, "nonexistent", ctx, {}),
122
+ ).rejects.toThrow("nonexistent");
123
+ });
124
+
125
+ it("throws ConnectorError when operation has no handler", async () => {
126
+ const ctx = createMockCtx({});
127
+ const connector: ConnectorDefinition = {
128
+ ...createMockConnector(),
129
+ operations: {
130
+ search: {
131
+ description: "No upstream",
132
+ input: z.object({ query: z.string() }),
133
+ output: z.object({ results: z.array(z.string()) }),
134
+ handler: undefined as never,
135
+ },
136
+ },
137
+ };
138
+
139
+ await expect(
140
+ executeOperation(connector, "search", ctx, { query: "test" }),
141
+ ).rejects.toThrow(TypeError);
142
+ });
143
+
144
+ it("auto-refreshes on 401 response if auth is configured", async () => {
145
+ let callCount = 0;
146
+ const ctx: ConnectorContext = {
147
+ ...createMockCtx({}),
148
+ tls: {
149
+ fetch: mock(async () => {
150
+ callCount++;
151
+ const responseStatus = callCount === 1 ? 401 : 200;
152
+ return {
153
+ status: responseStatus,
154
+ ok: responseStatus >= 200 && responseStatus < 300,
155
+ headers: {},
156
+ rawHeaders: [],
157
+ body: "",
158
+ cookies: {
159
+ get: () => undefined,
160
+ getAll: () => ({}),
161
+ toString: () => "",
162
+ },
163
+ json: async <T>() => ({ items: [] }) as T,
164
+ };
165
+ }),
166
+ createSession: mock(() => ({
167
+ fetch: async () => ({
168
+ status: 200,
169
+ ok: true,
170
+ headers: {},
171
+ rawHeaders: [] as [string, string][],
172
+ body: "",
173
+ cookies: {
174
+ get: () => undefined,
175
+ getAll: () => ({}),
176
+ toString: () => "",
177
+ },
178
+ json: async <T>() => ({}) as T,
179
+ }),
180
+ close: () => {},
181
+ })),
182
+ },
183
+ session: {
184
+ get: mock(async (key: string) => (key === "__auth__" ? "token" : null)),
185
+ set: mock(async () => {}),
186
+ delete: mock(async () => {}),
187
+ },
188
+ };
189
+
190
+ const authRefresh = mock(async () => {});
191
+ const connector = createMockConnector({
192
+ auth: {
193
+ mode: "credentials",
194
+ refresh: authRefresh,
195
+ },
196
+ handler: async () => {
197
+ callCount++;
198
+ if (callCount === 1) {
199
+ throw new TransportError("Unauthorized", { status: 401 });
200
+ }
201
+
202
+ return { results: [] };
203
+ },
204
+ });
205
+
206
+ const result = await executeOperation(connector, "search", ctx, {
207
+ query: "test",
208
+ });
209
+
210
+ expect(authRefresh).toHaveBeenCalledTimes(1);
211
+ expect(callCount).toBe(2);
212
+ expect(result).toEqual({ results: [] });
213
+ });
214
+ });