1import {
2 createMutationButton,
3 type MutationAction,
4 MutationClient,
5 type Reactive,
6 useMutate,
7} from "@clo/react-mutation";
8import { assertEquals, assertThrows } from "@std/assert";
9import { act, render, screen } from "@testing-library/react";
10import { userEvent } from "@testing-library/user-event";
11import type { FC, MouseEventHandler, ReactNode } from "react";
12import { test, vi } from "vitest";
13import { IterableStream } from "./share.ts";
14
15interface User {
16 id: string;
17}
18
19function mutableReactive<T>(value: T): Reactive<T> & { set(value: T): void } {
20 const listeners = new Set<() => void>();
21 return {
22 get: () => value,
23 set(next) {
24 value = next;
25 listeners.forEach((listener) => listener());
26 },
27 sub(listener) {
28 listeners.add(listener);
29 return () => listeners.delete(listener);
30 },
31 };
32}
33
34function createAuthClient(currentUser: () => User | null, withHandler = true, admin?: Reactive<boolean>) {
35 const unauthenticated: MutationAction[] = [];
36 const errors: string[] = [];
37 const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }, "admin">({
38 context: { contextValue: 42 },
39 userContext: {
40 get: () => {
41 const user = currentUser();
42 return user ? { user, accountId: "a1" } : null;
43 },
44 sub: () => () => {},
45 },
46 authScopes: admin ? { admin } : undefined,
47 handleUnauthenticated: withHandler ? (action) => unauthenticated.push(action) : undefined,
48 getOptimisticHelpers: () => ({}),
49 reportError: (message) => errors.push(message),
50 });
51
52 return { client, unauthenticated, errors };
53}
54
55test("authenticated mutations receive user in every context", async () => {
56 const currentUser = { id: "u1" };
57 const { client } = createAuthClient(() => currentUser);
58 const seen: string[] = [];
59 let snapshotValue = 0;
60
61 const mutation = client.define({
62 id: "test-auth",
63 auth: true,
64 mutate: async function(value: number) {
65 seen.push(`mutate:${this.user?.id}:${this.accountId}:${this.contextValue}:${value}`);
66 return value + 1;
67 },
68 key: ({ user, accountId, contextValue, args: [value] }) => {
69 seen.push(`key:${user?.id}:${accountId}:${contextValue}:${value}`);
70 return String(value);
71 },
72 snapshot: ({ user, accountId, contextValue, args: [value] }) => {
73 seen.push(`snapshot:${user?.id}:${accountId}:${contextValue}:${value}`);
74 return snapshotValue;
75 },
76 optimistic: ({ user, accountId, contextValue, args: [value] }) => {
77 seen.push(`optimistic:${user?.id}:${accountId}:${contextValue}:${value}`);
78 snapshotValue += 1;
79 },
80 describe: ({ user, accountId, contextValue, args: [value] }) => {
81 seen.push(`describe:${user?.id}:${accountId}:${contextValue}:${value}`);
82 return "Test auth";
83 },
84 describeResult: ({ user, accountId, contextValue, args: [value], result }) => {
85 seen.push(`result:${user?.id}:${accountId}:${contextValue}:${value}:${result}`);
86 return "Tested auth";
87 },
88 refetchOnSuccess: false,
89 });
90
91 assertEquals(mutation.key([1]), "\"1\"");
92 assertEquals(mutation.describe(1), "Test auth");
93 assertEquals(mutation.describeResult?.([1], 2), "Tested auth");
94 assertEquals(await mutation.runAsHeadlessPromise(1, {}), 2);
95
96 assertEquals(seen, [
97 "key:u1:a1:42:1",
98 "describe:u1:a1:42:1",
99 "result:u1:a1:42:1:2",
100 "key:u1:a1:42:1",
101 "snapshot:u1:a1:42:1",
102 "optimistic:u1:a1:42:1",
103 "snapshot:u1:a1:42:1",
104 "mutate:u1:a1:42:1",
105 ]);
106});
107
108test("authenticated mutations do not run while unauthenticated", async () => {
109 const { client, unauthenticated } = createAuthClient(() => null);
110 const localUnauthenticated: string[] = [];
111 const mutate = vi.fn(async () => "ok");
112 const optimistic = vi.fn();
113
114 const mutation = client.define({
115 id: "test-auth",
116 auth: true,
117 mutate,
118 optimistic,
119 describe: ({ user, contextValue }) => `Test auth as ${user?.id ?? "guest"}:${contextValue}`,
120 describeResult: "Tested auth",
121 });
122
123 mutation.runWithOptions({
124 onUnauthenticated: () => localUnauthenticated.push("local"),
125 });
126 mutation.run();
127 await Promise.resolve();
128
129 assertEquals(localUnauthenticated, ["local"]);
130 assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth as guest:42", args: [] }]);
131 assertEquals(optimistic.mock.calls, []);
132 assertEquals(mutate.mock.calls, []);
133});
134
135test("duplicate mutation ids throw in production and replace in development", async () => {
136 const { client } = createAuthClient(() => null);
137 const options = {
138 id: "test-duplicate",
139 mutate: async () => "ok",
140 optimistic: () => {},
141 describe: "Test duplicate",
142 describeResult: null,
143 refetchOnSuccess: false,
144 };
145 client.define(options);
146
147 vi.stubEnv("NODE_ENV", "production");
148 try {
149 assertThrows(() => client.define(options), Error, "Mutation id \"test-duplicate\" is already registered.");
150 } finally {
151 vi.unstubAllEnvs();
152 }
153
154 const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
155 try {
156 const replacement = vi.fn(async () => "ok");
157 client.define({ ...options, mutate: replacement });
158 assertEquals(errorSpy.mock.calls.length, 1);
159
160 client.run({ id: "test-duplicate", args: [] });
161 assertEquals(replacement.mock.calls, [[]]);
162 } finally {
163 errorSpy.mockRestore();
164 }
165});
166
167test("unauthenticated actions survive JSON storage and restore after sign-in", async () => {
168 let currentUser: User | null = null;
169 const { client, unauthenticated } = createAuthClient(() => currentUser);
170 const mutate = vi.fn(async (value: number, note: string) => `${value}:${note}`);
171
172 client.define({
173 id: "test-restore",
174 auth: true,
175 mutate,
176 optimistic: () => {},
177 describe: "Test restore",
178 describeResult: null,
179 refetchOnSuccess: false,
180 });
181
182 client.run({ id: "test-restore", args: [1, "hello"] });
183 assertEquals(mutate.mock.calls, []);
184 assertEquals(unauthenticated, [{ id: "test-restore", description: "Test restore", args: [1, "hello"] }]);
185
186 const stored: MutationAction = JSON.parse(JSON.stringify(unauthenticated[0]));
187 currentUser = { id: "u1" };
188 client.run(stored);
189 await Promise.resolve();
190
191 assertEquals(mutate.mock.calls, [[1, "hello"]]);
192 assertThrows(() => client.run({ id: "test-unknown", args: [] }), Error, "not registered");
193});
194
195test("useMutate derives isAllowed and isDisabled from authentication", async () => {
196 const user = userEvent.setup({ delay: null });
197 let currentUser: User | null = null;
198 const { client } = createAuthClient(() => currentUser, false);
199 const s = new IterableStream<string>();
200
201 const mutation = client.define({
202 id: "test-auth",
203 auth: true,
204 mutate: async () => (await s.next()).value,
205 optimistic: () => {},
206 describe: "Test auth",
207 describeResult: "Tested auth",
208 });
209
210 let renders: Array<{ isAllowed: boolean; isDisabled: boolean; isMutating: boolean }> = [];
211 function TestComponent() {
212 const { run, isAllowed, isDisabled, isMutating } = useMutate(mutation);
213 renders.push({ isAllowed, isDisabled, isMutating });
214
215 return <button data-testid="run" onClick={() => run()}>run</button>;
216 }
217
218 const view = render(<TestComponent />);
219 assertEquals(renders, [{ isAllowed: false, isDisabled: true, isMutating: false }]);
220 renders = [];
221
222 currentUser = { id: "u1" };
223 view.rerender(<TestComponent />);
224 assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]);
225 renders = [];
226
227 await act(() => user.click(screen.getByTestId("run")));
228 assertEquals(renders, [{ isAllowed: true, isDisabled: true, isMutating: true }]);
229 renders = [];
230
231 await act(async () => {
232 s.push("ok");
233 });
234 assertEquals(renders, [{ isAllowed: true, isDisabled: false, isMutating: false }]);
235});
236
237test("useMutate reacts to userContext changes", () => {
238 const userContext = mutableReactive<{ user: User; accountId: string } | null>(null);
239 const client = new MutationClient<{ contextValue: number }, {}, { user: User; accountId: string }>({
240 context: { contextValue: 42 },
241 userContext,
242 getOptimisticHelpers: () => ({}),
243 reportError: () => {},
244 });
245 const mutation = client.define({
246 id: "test-auth",
247 auth: true,
248 mutate: async () => "ok",
249 optimistic: () => {},
250 describe: "Test auth",
251 describeResult: "Tested auth",
252 });
253
254 let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = [];
255 function TestComponent() {
256 const { isAllowed, isDisabled } = useMutate(mutation);
257 renders.push({ isAllowed, isDisabled });
258 return null;
259 }
260
261 render(<TestComponent />);
262 assertEquals(renders, [{ isAllowed: false, isDisabled: true }]);
263 renders = [];
264
265 act(() => userContext.set({ user: { id: "u1" }, accountId: "a1" }));
266 assertEquals(renders, [{ isAllowed: true, isDisabled: false }]);
267});
268
269test("useMutate reacts to mutation isAllowed changes", () => {
270 const isAllowed = mutableReactive(false);
271 const { client } = createAuthClient(() => null);
272 const mutation = client.define({
273 id: "test-allowed",
274 isAllowed,
275 mutate: async () => "ok",
276 optimistic: () => {},
277 describe: "Test allowed",
278 describeResult: "Tested allowed",
279 });
280
281 let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = [];
282 function TestComponent() {
283 const { isAllowed, isDisabled } = useMutate(mutation);
284 renders.push({ isAllowed, isDisabled });
285 return null;
286 }
287
288 render(<TestComponent />);
289 assertEquals(renders, [{ isAllowed: false, isDisabled: true }]);
290 renders = [];
291
292 act(() => isAllowed.set(true));
293 assertEquals(renders, [{ isAllowed: true, isDisabled: false }]);
294});
295
296test("unauthenticated with a handler stays enabled and routes clicks to it", async () => {
297 const user = userEvent.setup({ delay: null });
298 const { client, unauthenticated } = createAuthClient(() => null);
299 const mutate = vi.fn(async () => "ok");
300
301 const mutation = client.define({
302 id: "test-auth",
303 auth: true,
304 mutate,
305 optimistic: () => {},
306 describe: "Test auth",
307 describeResult: "Tested auth",
308 });
309
310 let lastRender = { isAllowed: true, isDisabled: true };
311 function TestComponent() {
312 const { run, isAllowed, isDisabled } = useMutate(mutation);
313 lastRender = { isAllowed, isDisabled };
314 return <button data-testid="run" onClick={() => run()}>run</button>;
315 }
316
317 render(<TestComponent />);
318 assertEquals(lastRender, { isAllowed: false, isDisabled: false });
319
320 await act(() => user.click(screen.getByTestId("run")));
321 assertEquals(unauthenticated, [{ id: "test-auth", description: "Test auth", args: [] }]);
322 assertEquals(mutate.mock.calls, []);
323});
324
325test("running while isAllowed is false reports an error instead of the sign-in flow", () => {
326 const isAllowed = mutableReactive(false);
327 const { client, unauthenticated, errors } = createAuthClient(() => null);
328 const mutate = vi.fn(async () => "ok");
329
330 const mutation = client.define({
331 id: "test-not-allowed",
332 isAllowed,
333 mutate,
334 optimistic: () => {},
335 describe: "Test allowed",
336 describeResult: null,
337 });
338
339 mutation.run();
340
341 assertEquals(unauthenticated, []);
342 assertEquals(mutate.mock.calls, []);
343 assertEquals(errors, ["Could not test allowed: Mutation is not allowed."]);
344});
345
346test("MutationButton hides authenticated mutations without a sign-in flow", async () => {
347 const user = userEvent.setup({ delay: null });
348 const { client } = createAuthClient(() => null, false);
349 const mutate = vi.fn(async () => "ok");
350
351 const mutation = client.define({
352 id: "test-auth",
353 auth: true,
354 mutate,
355 optimistic: () => {},
356 describe: "Test auth",
357 describeResult: "Tested auth",
358 });
359
360 const MutationButtonBase: FC<{
361 children?: ReactNode;
362 disabled?: boolean;
363 isPending: boolean;
364 onClick: MouseEventHandler<HTMLElement> | undefined;
365 }> = ({ children, disabled, isPending, onClick }) => (
366 <button data-testid="button" disabled={disabled || isPending} onClick={onClick}>
367 {children}
368 </button>
369 );
370 const MutationButton = createMutationButton(MutationButtonBase);
371
372 const view = render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>);
373 assertEquals(screen.queryByTestId("button"), null);
374
375 view.rerender(<MutationButton mutation={mutation} args={[]} showNotAllowed>run</MutationButton>);
376 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true);
377 await act(() => user.click(screen.getByTestId("button")));
378 assertEquals(mutate.mock.calls, []);
379});
380
381test("auth scopes gate mutations without routing to sign-in", () => {
382 let currentUser: User | null = { id: "u1" };
383 const admin = mutableReactive(false);
384 const { client, unauthenticated, errors } = createAuthClient(() => currentUser, true, admin);
385 const mutate = vi.fn(async () => "ok");
386
387 const mutation = client.define({
388 id: "test-admin",
389 auth: "admin",
390 mutate,
391 optimistic: () => {},
392 describe: "Test admin",
393 describeResult: null,
394 refetchOnSuccess: false,
395 });
396
397 assertEquals(mutation.isAllowed(), false);
398 assertEquals(mutation.isUnauthenticated(), false);
399
400 mutation.run();
401 assertEquals(unauthenticated, []);
402 assertEquals(mutate.mock.calls, []);
403 assertEquals(errors, ["Could not test admin: Mutation is not allowed."]);
404
405 admin.set(true);
406 assertEquals(mutation.isAllowed(), true);
407 mutation.run();
408 assertEquals(mutate.mock.calls, [[]]);
409
410 currentUser = null;
411 assertEquals(mutation.isAllowed(), false);
412 assertEquals(mutation.isUnauthenticated(), false);
413});
414
415test("defining an unknown auth scope throws", () => {
416 const { client } = createAuthClient(() => null);
417
418 assertThrows(
419 () =>
420 client.define({
421 id: "test-unknown-scope",
422 auth: "admin",
423 mutate: async () => "ok",
424 optimistic: () => {},
425 describe: "Test unknown scope",
426 describeResult: null,
427 }),
428 Error,
429 "Unknown auth scope \"admin\".",
430 );
431});
432
433test("useMutate reacts to auth scope changes", () => {
434 const admin = mutableReactive(false);
435 const { client } = createAuthClient(() => ({ id: "u1" }), true, admin);
436 const mutation = client.define({
437 id: "test-admin",
438 auth: "admin",
439 mutate: async () => "ok",
440 optimistic: () => {},
441 describe: "Test admin",
442 describeResult: null,
443 });
444
445 let renders: Array<{ isAllowed: boolean; isDisabled: boolean }> = [];
446 function TestComponent() {
447 const { isAllowed, isDisabled } = useMutate(mutation);
448 renders.push({ isAllowed, isDisabled });
449 return null;
450 }
451
452 render(<TestComponent />);
453 assertEquals(renders, [{ isAllowed: false, isDisabled: true }]);
454 renders = [];
455
456 act(() => admin.set(true));
457 assertEquals(renders, [{ isAllowed: true, isDisabled: false }]);
458});
459
460test("MutationButton hides non-allowed mutations unless showNotAllowed", () => {
461 const admin = mutableReactive(false);
462 const { client } = createAuthClient(() => ({ id: "u1" }), true, admin);
463 const mutation = client.define({
464 id: "test-admin",
465 auth: "admin",
466 mutate: async () => "ok",
467 optimistic: () => {},
468 describe: "Test admin",
469 describeResult: null,
470 });
471
472 const MutationButtonBase: FC<{
473 children?: ReactNode;
474 disabled?: boolean;
475 isPending: boolean;
476 onClick: MouseEventHandler<HTMLElement> | undefined;
477 }> = ({ children, disabled, isPending, onClick }) => (
478 <button data-testid="button" disabled={disabled || isPending} onClick={onClick}>
479 {children}
480 </button>
481 );
482 const MutationButton = createMutationButton(MutationButtonBase);
483
484 const view = render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>);
485 assertEquals(screen.queryByTestId("button"), null);
486
487 view.rerender(<MutationButton mutation={mutation} args={[]} showNotAllowed>run</MutationButton>);
488 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, true);
489
490 act(() => admin.set(true));
491 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false);
492});
493
494test("MutationButton stays visible for unauthenticated mutations with a sign-in flow", () => {
495 const { client } = createAuthClient(() => null);
496 const mutation = client.define({
497 id: "test-auth",
498 auth: true,
499 mutate: async () => "ok",
500 optimistic: () => {},
501 describe: "Test auth",
502 describeResult: "Tested auth",
503 });
504
505 const MutationButtonBase: FC<{
506 children?: ReactNode;
507 disabled?: boolean;
508 isPending: boolean;
509 onClick: MouseEventHandler<HTMLElement> | undefined;
510 }> = ({ children, disabled, isPending, onClick }) => (
511 <button data-testid="button" disabled={disabled || isPending} onClick={onClick}>
512 {children}
513 </button>
514 );
515 const MutationButton = createMutationButton(MutationButtonBase);
516
517 render(<MutationButton mutation={mutation} args={[]}>run</MutationButton>);
518 assertEquals((screen.getByTestId("button") as HTMLButtonElement).disabled, false);
519});
520
521test("mutation isAllowed can disable unrelated to auth", () => {
522 let allow = false;
523 const isAllowed: Reactive<boolean> = {
524 get: () => allow,
525 sub: () => () => {},
526 };
527 const { client } = createAuthClient(() => null);
528
529 const mutation = client.define({
530 id: "test-allowed",
531 isAllowed,
532 mutate: async () => "ok",
533 optimistic: () => {},
534 describe: "Test allowed",
535 describeResult: "Tested allowed",
536 });
537
538 assertEquals(mutation.isAllowed(), false);
539 allow = true;
540 assertEquals(mutation.isAllowed(), true);
541});
542
543test("authenticated mutation isAllowed receives user context", () => {
544 let currentUser: User | null = { id: "u1" };
545 let allow = true;
546 const isAllowed: Reactive<boolean> = {
547 get: () => allow,
548 sub: () => () => {},
549 };
550 const { client } = createAuthClient(() => currentUser);
551
552 const mutation = client.define({
553 id: "test-auth-allowed",
554 auth: true,
555 isAllowed,
556 mutate: async () => "ok",
557 optimistic: () => {},
558 describe: "Test allowed",
559 describeResult: "Tested allowed",
560 });
561
562 assertEquals(mutation.isAllowed(), true);
563 allow = false;
564 assertEquals(mutation.isAllowed(), false);
565 allow = true;
566 currentUser = null;
567 assertEquals(mutation.isAllowed(), false);
568});
569
570// Type-level: the serializable-argument constraint applies only to `auth: true`
571// mutations, which are the only ones captured into a replayable MutationAction.
572// Guarded by the package's `tsc --noEmit`; never executed.
573// deno-lint-ignore no-unused-vars
574function _serializableArgsConstraint() {
575 const { client } = createAuthClient(() => null, false, { get: () => true, sub: () => () => {} });
576 const base = { optimistic: () => {}, describe: "x", describeResult: null } as const;
577
578 // Non-auth mutations accept non-serializable arguments (File, interfaces).
579 client.define({ id: "t1", mutate: async (_file: File) => {}, ...base });
580
581 // Scoped mutations never serialize either, so they are unconstrained.
582 client.define({ id: "t2", auth: "admin", mutate: async (_file: File) => {}, ...base });
583
584 // `auth: true` with serializable arguments is allowed.
585 client.define({ id: "t3", auth: true, mutate: async (_id: string) => {}, ...base });
586
587 // `auth: true` with `interface`-typed arguments is allowed. A concrete `Json`
588 // value type would reject these for lacking an index signature; the mapped
589 // `Serializable` check accepts them.
590 interface Payload {
591 name: string;
592 nested: { count: number; tags: string[] };
593 }
594 client.define({ id: "t4", auth: true, mutate: async (_p: Payload) => {}, ...base });
595
596 // `auth: true` with a non-serializable argument is rejected. A function is
597 // never a registered leaf, so this holds regardless of augmentation below.
598 // @ts-expect-error a function is not serializable
599 client.define({ id: "t5", auth: true, mutate: async (_fn: () => void) => {}, ...base });
600
601 // Leading fixed arguments are checked even alongside a rest parameter.
602 // @ts-expect-error a non-serializable leading argument is rejected
603 client.define({ id: "t7", auth: true, mutate: async (_fn: () => void, ..._rest: number[]) => {}, ...base });
604
605 // A `File` argument is serializable here only because the `SerializableLeaves`
606 // augmentation below registers it; the library does not hard-code it.
607 client.define({ id: "t6", auth: true, mutate: async (_file: File) => {}, ...base });
608}
609
610// Type-level: consumers extend the serializable leaf set through the
611// `SerializableLeaves` registry, so a superset value (here `File`) becomes a
612// valid `auth: true` argument without the library forcing the type.
613declare module "../src/types.ts" {
614 interface SerializableLeaves {
615 file: File;
616 }
617}