@cplieger/actions 1.0.1 → 1.1.0

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.
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@cplieger/actions",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
7
- ".": "./src/index.ts"
7
+ ".": "./src/index.ts",
8
+ "./src/*": "./src/*"
8
9
  },
9
10
  "scripts": {
10
11
  "typecheck": "tsgo -project tsconfig.json",
@@ -34,6 +35,9 @@
34
35
  },
35
36
  "files": [
36
37
  "src/",
38
+ "!src/**/*.test.ts",
39
+ "!src/**/__test-helpers__",
40
+ "!src/**/fc-strict-setup.ts",
37
41
  "jsr.json"
38
42
  ]
39
43
  }
@@ -1,34 +0,0 @@
1
- /**
2
- * Shared action-test setup. Provides resetActionFramework() and mock factories.
3
- */
4
- import { vi } from "vitest";
5
-
6
- import { _resetForTest as resetDefine } from "../define.js";
7
- import { _resetForTest as resetRegistry } from "../registry.js";
8
- import { _resetForTest as resetCleanup } from "../cleanup.js";
9
- import { _resetNotifierForTest as resetNotifier } from "../notifier.js";
10
- import { _resetTransportForTest as resetTransport } from "../transport.js";
11
-
12
- /** Resets define, registry, cleanup, notifier, and transport modules. Call in beforeEach(). */
13
- export function resetActionFramework(): void {
14
- resetDefine();
15
- resetRegistry();
16
- resetCleanup();
17
- resetNotifier();
18
- resetTransport();
19
- }
20
-
21
- /** Canonical notifier mock factory for vi.mock("../notifier.js", mockNotifier) */
22
- export const mockNotifier = () => ({
23
- configure: vi.fn(),
24
- notifySuccess: vi.fn(),
25
- notifyError: vi.fn(),
26
- _resetNotifierForTest: vi.fn(),
27
- });
28
-
29
- /** Canonical transport mock factory for vi.mock("../transport.js", mockTransport) */
30
- export const mockTransport = () => ({
31
- configureTransport: vi.fn(),
32
- transportAction: vi.fn(),
33
- _resetTransportForTest: vi.fn(),
34
- });
@@ -1,163 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // Tests for apiAction idempotency key and edge-case response handling.
3
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
-
5
- vi.mock("./notifier.js", () => ({
6
- configure: vi.fn(),
7
- notifySuccess: vi.fn(),
8
- notifyError: vi.fn(),
9
- _resetNotifierForTest: vi.fn(),
10
- }));
11
-
12
- import { apiAction } from "./api.js";
13
- import { IDEMPOTENCY_HEADER, _resetForTest as resetDefine } from "./define.js";
14
- import { _resetForTest as resetRegistry, recentLog } from "./registry.js";
15
- import { _resetForTest as resetCleanup } from "./cleanup.js";
16
-
17
- const mockFetch = vi.fn();
18
-
19
- beforeEach(() => {
20
- resetDefine();
21
- resetRegistry();
22
- resetCleanup();
23
- mockFetch.mockReset();
24
- vi.stubGlobal("fetch", mockFetch);
25
- });
26
-
27
- afterEach(() => {
28
- vi.restoreAllMocks();
29
- });
30
-
31
- describe("apiAction — idempotency key", () => {
32
- it("sends Idempotency-Key header when idempotencyKey: true", async () => {
33
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
34
- const action = apiAction<{ id: string }>({
35
- name: "test.idem",
36
- request: ({ id }) => ({ method: "POST", path: `/api/${id}`, body: { x: 1 } }),
37
- idempotencyKey: true,
38
- error: "Failed",
39
- });
40
- await action.dispatch({ id: "abc" });
41
- const [, opts] = mockFetch.mock.calls[0]!;
42
- const hdrKey = IDEMPOTENCY_HEADER.toLowerCase();
43
- expect(opts.headers[hdrKey]).toEqual(expect.any(String));
44
- expect(opts.headers[hdrKey].length).toBeGreaterThan(5);
45
- });
46
-
47
- it("does NOT send Idempotency-Key when not configured", async () => {
48
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
49
- const action = apiAction<undefined>({
50
- name: "test.no_idem",
51
- request: () => ({ method: "POST", path: "/api/x", body: {} }),
52
- error: "Failed",
53
- });
54
- await action.dispatch(undefined);
55
- const [, opts] = mockFetch.mock.calls[0]!;
56
- expect(opts.headers?.[IDEMPOTENCY_HEADER.toLowerCase()]).toBeUndefined();
57
- });
58
-
59
- it("idempotencyKey function receives args", async () => {
60
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
61
- const action = apiAction<{ id: string }>({
62
- name: "test.idem_fn",
63
- request: ({ id }) => ({ method: "POST", path: `/api/${id}`, body: {} }),
64
- idempotencyKey: (args) => `custom-${args.id}`,
65
- error: "Failed",
66
- });
67
- await action.dispatch({ id: "xyz" });
68
- const [, opts] = mockFetch.mock.calls[0]!;
69
- expect(opts.headers[IDEMPOTENCY_HEADER.toLowerCase()]).toBe("custom-xyz");
70
- });
71
- });
72
-
73
- describe("apiAction — response edge cases", () => {
74
- it("handles empty body on DELETE gracefully", async () => {
75
- mockFetch.mockResolvedValue(new Response("", { status: 200 }));
76
- const action = apiAction<undefined>({
77
- name: "test.empty_delete",
78
- request: () => ({ method: "DELETE", path: "/api/item/1" }),
79
- error: "Failed",
80
- });
81
- const result = await action.dispatch(undefined);
82
- expect(result).toBeUndefined();
83
- expect(recentLog()[0]?.status).toBe("success");
84
- });
85
-
86
- it("throws ActionError on non-JSON response body", async () => {
87
- mockFetch.mockResolvedValue(
88
- new Response("<html>error</html>", {
89
- status: 200,
90
- headers: { "Content-Type": "text/html" },
91
- }),
92
- );
93
- const action = apiAction<undefined>({
94
- name: "test.bad_json",
95
- request: () => ({ method: "GET", path: "/api/broken" }),
96
- error: "Parse failed",
97
- });
98
- const result = await action.dispatch(undefined);
99
- expect(result).toBeNull();
100
- expect(recentLog()[0]?.status).toBe("error");
101
- expect(recentLog()[0]?.error?.message).toContain("response not JSON");
102
- });
103
-
104
- it("falls back to HTTP status string when error body is not JSON", async () => {
105
- mockFetch.mockResolvedValue(new Response("plain text error", { status: 502 }));
106
- const action = apiAction<undefined>({
107
- name: "test.non_json_err",
108
- request: () => ({ method: "GET", path: "/api/down" }),
109
- error: "Server error",
110
- });
111
- const result = await action.dispatch(undefined);
112
- expect(result).toBeNull();
113
- expect(recentLog()[0]?.error?.message).toBe("HTTP 502");
114
- expect(recentLog()[0]?.error?.status).toBe(502);
115
- });
116
-
117
- it("GET request does not send Content-Type or body", async () => {
118
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ v: 1 }), { status: 200 }));
119
- const action = apiAction<undefined>({
120
- name: "test.get_clean",
121
- request: () => ({ method: "GET", path: "/api/data" }),
122
- error: "Failed",
123
- });
124
- await action.dispatch(undefined);
125
- const [, opts] = mockFetch.mock.calls[0]!;
126
- expect(opts.body).toBeUndefined();
127
- expect(opts.headers?.["Content-Type"]).toBeUndefined();
128
- });
129
- });
130
-
131
- describe("apiAction — error code propagation", () => {
132
- it("propagates code from JSON error body to ActionError", async () => {
133
- mockFetch.mockResolvedValue(
134
- new Response(JSON.stringify({ error: "rate limited", code: "rate_limit" }), { status: 429 }),
135
- );
136
- const action = apiAction<undefined>({
137
- name: "test.code_prop",
138
- request: () => ({ method: "POST", path: "/api/limited", body: {} }),
139
- error: false,
140
- });
141
- await action.dispatch(undefined);
142
- const entry = recentLog()[0]!;
143
- expect(entry.status).toBe("error");
144
- expect(entry.error?.message).toBe("rate limited");
145
- expect(entry.error?.status).toBe(429);
146
- expect(entry.error?.code).toBe("rate_limit");
147
- });
148
-
149
- it("omits code when error body has no code field", async () => {
150
- mockFetch.mockResolvedValue(
151
- new Response(JSON.stringify({ error: "not found" }), { status: 404 }),
152
- );
153
- const action = apiAction<undefined>({
154
- name: "test.no_code",
155
- request: () => ({ method: "GET", path: "/api/missing" }),
156
- error: false,
157
- });
158
- await action.dispatch(undefined);
159
- const entry = recentLog()[0]!;
160
- expect(entry.error?.message).toBe("not found");
161
- expect(entry.error?.code).toBeUndefined();
162
- });
163
- });
package/src/api.test.ts DELETED
@@ -1,96 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
3
- import { resetActionFramework } from "./__test-helpers__/action-test-setup.js";
4
- vi.mock("./notifier.js", () => ({
5
- configure: vi.fn(),
6
- notifySuccess: vi.fn(),
7
- notifyError: vi.fn(),
8
- _resetNotifierForTest: vi.fn(),
9
- }));
10
- import { apiAction } from "./api.js";
11
- import { recentLog } from "./registry.js";
12
-
13
- const mockFetch = vi.fn();
14
-
15
- beforeEach(() => {
16
- resetActionFramework();
17
- mockFetch.mockReset();
18
- vi.stubGlobal("fetch", mockFetch);
19
- });
20
- afterEach(() => {
21
- vi.restoreAllMocks();
22
- });
23
-
24
- const testAction = () =>
25
- apiAction<{ id: string }, { name: string }>({
26
- name: "test.api",
27
- request: ({ id }) => ({ method: "GET", path: `/api/items/${id}` }),
28
- error: "Test failed",
29
- });
30
-
31
- describe("apiAction", () => {
32
- it("returns parsed JSON on 200", async () => {
33
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ name: "foo" }), { status: 200 }));
34
- const action = testAction();
35
- const result = await action.dispatch({ id: "1" });
36
- expect(result).toEqual({ name: "foo" });
37
- expect(recentLog()[0]?.status).toBe("success");
38
- });
39
-
40
- it("returns undefined on 204 (no JSON parse)", async () => {
41
- mockFetch.mockResolvedValue(new Response(null, { status: 204 }));
42
- const action = testAction();
43
- const result = await action.dispatch({ id: "1" });
44
- expect(result).toBeUndefined();
45
- });
46
-
47
- it("throws ActionError with code 'timeout' on TimeoutError DOMException", async () => {
48
- mockFetch.mockRejectedValue(new DOMException("The operation timed out", "TimeoutError"));
49
- const action = testAction();
50
- const result = await action.dispatch({ id: "1" });
51
- expect(result).toBeNull();
52
- expect(recentLog()[0]?.error?.code).toBe("timeout");
53
- });
54
-
55
- it("throws ActionError with code 'cancelled' on AbortError when signal.aborted", async () => {
56
- mockFetch.mockRejectedValue(new DOMException("The operation was aborted", "AbortError"));
57
- const action = testAction();
58
- const promise = action.dispatch({ id: "1" });
59
- action.cancel();
60
- await promise;
61
- expect(recentLog()[0]?.status).toBe("cancelled");
62
- });
63
-
64
- it("throws ActionError with code 'network' on TypeError (Failed to fetch)", async () => {
65
- mockFetch.mockRejectedValue(new TypeError("Failed to fetch"));
66
- const action = testAction();
67
- await action.dispatch({ id: "1" });
68
- expect(recentLog()[0]?.error?.code).toBe("network");
69
- });
70
-
71
- it("throws ActionError with status + body.error message on non-OK response", async () => {
72
- mockFetch.mockResolvedValue(
73
- new Response(JSON.stringify({ error: "Not found" }), { status: 404 }),
74
- );
75
- const action = testAction();
76
- await action.dispatch({ id: "1" });
77
- const log = recentLog()[0];
78
- expect(log?.error?.status).toBe(404);
79
- expect(log?.error?.message).toBe("Not found");
80
- });
81
-
82
- it("POST sends JSON body with Content-Type header", async () => {
83
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
84
- const action = apiAction<{ name: string }>({
85
- name: "test.post",
86
- request: ({ name }) => ({ method: "POST", path: "/api/items", body: { name } }),
87
- error: "Failed",
88
- });
89
- await action.dispatch({ name: "foo" });
90
- const [url, opts] = mockFetch.mock.calls[0]!;
91
- expect(url).toBe("/api/items");
92
- expect(opts.method).toBe("POST");
93
- expect(opts.headers).toEqual({ "content-type": "application/json" });
94
- expect(opts.body).toBe(JSON.stringify({ name: "foo" }));
95
- });
96
- });
@@ -1,102 +0,0 @@
1
- // @vitest-environment happy-dom
2
- import { describe, it, expect, vi, beforeEach } from "vitest";
3
- import { resetActionFramework } from "./__test-helpers__/action-test-setup.js";
4
- vi.mock("./notifier.js", () => ({
5
- configure: vi.fn(),
6
- notifySuccess: vi.fn(),
7
- notifyError: vi.fn(),
8
- _resetNotifierForTest: vi.fn(),
9
- }));
10
- import { defineAction } from "./define.js";
11
- import { registerCleanup, _cancelAllForTest as cancelAllPending } from "./cleanup.js";
12
-
13
- beforeEach(() => {
14
- resetActionFramework();
15
- vi.clearAllMocks();
16
- });
17
-
18
- describe("cancelAllPending + registered cleanup", () => {
19
- it("aborts in-flight action via action.cancel() on global cleanup", async () => {
20
- let aborted = false;
21
- const action = defineAction({
22
- name: "test.cleanup1",
23
- run: (_args, signal) =>
24
- new Promise<void>((_, reject) => {
25
- signal.addEventListener("abort", () => {
26
- aborted = true;
27
- reject(new Error("aborted"));
28
- });
29
- }),
30
- });
31
- const p = action.dispatch({});
32
- cancelAllPending();
33
- await p;
34
- expect(aborted).toBe(true);
35
- });
36
-
37
- it("invokes registered cleanup hooks", () => {
38
- const fn1 = vi.fn();
39
- const fn2 = vi.fn();
40
- registerCleanup(fn1);
41
- registerCleanup(fn2);
42
- cancelAllPending();
43
- expect(fn1).toHaveBeenCalledOnce();
44
- expect(fn2).toHaveBeenCalledOnce();
45
- });
46
-
47
- it("returns an unregister function for cleanup hooks", () => {
48
- const fn = vi.fn();
49
- const unreg = registerCleanup(fn);
50
- unreg();
51
- cancelAllPending();
52
- expect(fn).not.toHaveBeenCalled();
53
- });
54
-
55
- it("a throwing cleanup hook does not stop other hooks", () => {
56
- const fn1 = vi.fn(() => {
57
- throw new Error("bad");
58
- });
59
- const fn2 = vi.fn();
60
- const consoleErr = vi.spyOn(console, "error").mockImplementation(() => undefined);
61
- registerCleanup(fn1);
62
- registerCleanup(fn2);
63
- cancelAllPending();
64
- expect(fn1).toHaveBeenCalledOnce();
65
- expect(fn2).toHaveBeenCalledOnce();
66
- consoleErr.mockRestore();
67
- });
68
-
69
- it("cancels multiple actions and runs hooks in one cancelAllPending call", async () => {
70
- let abort1 = false;
71
- let abort2 = false;
72
- const a1 = defineAction({
73
- name: "test.cleanup-multi-1",
74
- run: (_args, signal) =>
75
- new Promise<void>((_, reject) => {
76
- signal.addEventListener("abort", () => {
77
- abort1 = true;
78
- reject(new Error("aborted"));
79
- });
80
- }),
81
- });
82
- const a2 = defineAction({
83
- name: "test.cleanup-multi-2",
84
- run: (_args, signal) =>
85
- new Promise<void>((_, reject) => {
86
- signal.addEventListener("abort", () => {
87
- abort2 = true;
88
- reject(new Error("aborted"));
89
- });
90
- }),
91
- });
92
- const hook = vi.fn();
93
- registerCleanup(hook);
94
- const p1 = a1.dispatch({});
95
- const p2 = a2.dispatch({});
96
- cancelAllPending();
97
- await Promise.all([p1, p2]);
98
- expect(abort1).toBe(true);
99
- expect(abort2).toBe(true);
100
- expect(hook).toHaveBeenCalledOnce();
101
- });
102
- });
@@ -1,219 +0,0 @@
1
- // @vitest-environment happy-dom
2
- // Tests for the configureApi HTTP-customization seam.
3
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
4
-
5
- vi.mock("./notifier.js", () => ({
6
- configure: vi.fn(),
7
- notifySuccess: vi.fn(),
8
- notifyError: vi.fn(),
9
- _resetNotifierForTest: vi.fn(),
10
- }));
11
-
12
- import { apiAction, configureApi, _resetApiConfigForTest } from "./api.js";
13
- import { _resetForTest as resetDefine } from "./define.js";
14
- import { _resetForTest as resetRegistry } from "./registry.js";
15
- import { _resetForTest as resetCleanup } from "./cleanup.js";
16
-
17
- const mockFetch = vi.fn();
18
-
19
- beforeEach(() => {
20
- resetDefine();
21
- resetRegistry();
22
- resetCleanup();
23
- _resetApiConfigForTest();
24
- mockFetch.mockReset();
25
- vi.stubGlobal("fetch", mockFetch);
26
- });
27
-
28
- afterEach(() => {
29
- vi.restoreAllMocks();
30
- });
31
-
32
- describe("configureApi — baseUrl", () => {
33
- it("prepends baseUrl to request path", async () => {
34
- configureApi({ baseUrl: "https://api.example.com/v1" });
35
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
36
- const action = apiAction<string>({
37
- name: "base.url",
38
- request: (id) => ({ method: "GET", path: `/items/${id}` }),
39
- });
40
- await action.dispatch("42");
41
- expect(mockFetch.mock.calls[0]![0]).toBe("https://api.example.com/v1/items/42");
42
- });
43
-
44
- it("works without baseUrl (relative path)", async () => {
45
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
46
- const action = apiAction<string>({
47
- name: "no.base",
48
- request: (id) => ({ method: "GET", path: `/items/${id}` }),
49
- });
50
- await action.dispatch("1");
51
- expect(mockFetch.mock.calls[0]![0]).toBe("/items/1");
52
- });
53
- });
54
-
55
- describe("configureApi — prepareHeaders", () => {
56
- it("injects auth headers on every request", async () => {
57
- configureApi({
58
- prepareHeaders: (headers) => {
59
- headers.set("Authorization", "Bearer test-token");
60
- },
61
- });
62
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
63
- const action = apiAction<string>({
64
- name: "auth.header",
65
- request: () => ({ method: "GET", path: "/me" }),
66
- });
67
- await action.dispatch("x");
68
- const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
69
- expect(headers["authorization"]).toBe("Bearer test-token");
70
- });
71
-
72
- it("supports async prepareHeaders", async () => {
73
- configureApi({
74
- prepareHeaders: async (headers) => {
75
- await Promise.resolve();
76
- headers.set("X-CSRF", "async-token");
77
- },
78
- });
79
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
80
- const action = apiAction<string>({
81
- name: "async.header",
82
- request: () => ({ method: "GET", path: "/x" }),
83
- });
84
- await action.dispatch("x");
85
- const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
86
- expect(headers["x-csrf"]).toBe("async-token");
87
- });
88
-
89
- it("receives the request spec as context", async () => {
90
- const spy = vi.fn();
91
- configureApi({
92
- prepareHeaders: (headers, ctx) => {
93
- spy(ctx.spec);
94
- },
95
- });
96
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
97
- const action = apiAction<string>({
98
- name: "ctx.spec",
99
- request: (id) => ({ method: "POST", path: `/items/${id}`, body: { id } }),
100
- });
101
- await action.dispatch("5");
102
- expect(spy).toHaveBeenCalledWith(expect.objectContaining({ method: "POST", path: "/items/5" }));
103
- });
104
-
105
- it("does not override per-request headers set in RequestSpec", async () => {
106
- configureApi({
107
- prepareHeaders: (headers) => {
108
- headers.set("X-Global", "global");
109
- headers.set("X-Override", "from-global");
110
- },
111
- });
112
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
113
- // Per-request headers are set before prepareHeaders, so prepareHeaders wins
114
- // This matches RTK behavior where prepareHeaders runs last
115
- const action = apiAction<string>({
116
- name: "override.test",
117
- request: () => ({ method: "GET", path: "/x", headers: { "X-Override": "from-spec" } }),
118
- });
119
- await action.dispatch("x");
120
- const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
121
- expect(headers["x-global"]).toBe("global");
122
- // prepareHeaders runs after spec headers, so it overrides
123
- expect(headers["x-override"]).toBe("from-global");
124
- });
125
- });
126
-
127
- describe("configureApi — credentials", () => {
128
- it("sets credentials on every request", async () => {
129
- configureApi({ credentials: "include" });
130
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
131
- const action = apiAction<string>({
132
- name: "creds",
133
- request: () => ({ method: "GET", path: "/x" }),
134
- });
135
- await action.dispatch("x");
136
- expect(mockFetch.mock.calls[0]![1].credentials).toBe("include");
137
- });
138
-
139
- it("omits credentials when not configured", async () => {
140
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
141
- const action = apiAction<string>({
142
- name: "no.creds",
143
- request: () => ({ method: "GET", path: "/x" }),
144
- });
145
- await action.dispatch("x");
146
- expect(mockFetch.mock.calls[0]![1].credentials).toBeUndefined();
147
- });
148
- });
149
-
150
- describe("configureApi — fetchFn", () => {
151
- it("uses custom fetch implementation", async () => {
152
- const customFetch = vi
153
- .fn()
154
- .mockResolvedValue(new Response(JSON.stringify({ custom: true }), { status: 200 }));
155
- configureApi({ fetchFn: customFetch });
156
- const action = apiAction<string>({
157
- name: "custom.fetch",
158
- request: () => ({ method: "GET", path: "/x" }),
159
- });
160
- const result = await action.dispatch("x");
161
- expect(customFetch).toHaveBeenCalled();
162
- expect(mockFetch).not.toHaveBeenCalled();
163
- expect(result).toEqual({ custom: true });
164
- });
165
- });
166
-
167
- describe("configureApi — combined", () => {
168
- it("baseUrl + prepareHeaders + credentials work together", async () => {
169
- configureApi({
170
- baseUrl: "https://api.test.io",
171
- credentials: "include",
172
- prepareHeaders: (headers) => {
173
- headers.set("Authorization", "Bearer combo");
174
- },
175
- });
176
- mockFetch.mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
177
- const action = apiAction<{ name: string }>({
178
- name: "combo",
179
- request: ({ name }) => ({ method: "POST", path: "/items", body: { name } }),
180
- });
181
- await action.dispatch({ name: "test" });
182
- const [url, opts] = mockFetch.mock.calls[0]!;
183
- expect(url).toBe("https://api.test.io/items");
184
- expect(opts.credentials).toBe("include");
185
- const headers = opts.headers as Record<string, string>;
186
- expect(headers["authorization"]).toBe("Bearer combo");
187
- expect(headers["content-type"]).toBe("application/json");
188
- });
189
- });
190
-
191
- describe("RequestSpec.headers — per-request headers", () => {
192
- it("sends per-request headers on GET", async () => {
193
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
194
- const action = apiAction<string>({
195
- name: "per.req.get",
196
- request: () => ({ method: "GET", path: "/x", headers: { "X-Custom": "val" } }),
197
- });
198
- await action.dispatch("x");
199
- const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
200
- expect(headers["x-custom"]).toBe("val");
201
- });
202
-
203
- it("sends per-request headers on POST alongside Content-Type", async () => {
204
- mockFetch.mockResolvedValue(new Response(JSON.stringify({}), { status: 200 }));
205
- const action = apiAction<string>({
206
- name: "per.req.post",
207
- request: () => ({
208
- method: "POST",
209
- path: "/x",
210
- body: { a: 1 },
211
- headers: { "X-Request-Id": "abc" },
212
- }),
213
- });
214
- await action.dispatch("x");
215
- const headers = mockFetch.mock.calls[0]![1].headers as Record<string, string>;
216
- expect(headers["content-type"]).toBe("application/json");
217
- expect(headers["x-request-id"]).toBe("abc");
218
- });
219
- });