@svadmin/core 0.0.3 → 0.3.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.
Files changed (41) hide show
  1. package/package.json +4 -1
  2. package/src/audit.ts +37 -0
  3. package/src/auth-hooks.svelte.ts +293 -0
  4. package/src/auth-hooks.test.ts +205 -0
  5. package/src/context.ts +77 -15
  6. package/src/data-transfer.test.ts +118 -0
  7. package/src/data-transfer.ts +148 -70
  8. package/src/form-hooks.svelte.ts +266 -0
  9. package/src/helpers.ts +157 -0
  10. package/src/hook-utils.svelte.ts +121 -0
  11. package/src/hooks.svelte.ts +278 -496
  12. package/src/http-error.test.ts +85 -0
  13. package/src/i18n.svelte.ts +24 -4
  14. package/src/i18n.test.ts +90 -0
  15. package/src/index.ts +51 -8
  16. package/src/inferencer-openapi.test.ts +192 -0
  17. package/src/inferencer-openapi.ts +290 -0
  18. package/src/inferencer.test.ts +189 -0
  19. package/src/inferencer.ts +247 -0
  20. package/src/live-sse.ts +146 -0
  21. package/src/live-websocket.ts +141 -0
  22. package/src/live.test.ts +134 -0
  23. package/src/live.ts +60 -8
  24. package/src/mutation-hooks.svelte.ts +301 -0
  25. package/src/options.ts +95 -0
  26. package/src/permissions.test.ts +55 -0
  27. package/src/query-hooks.svelte.ts +213 -0
  28. package/src/query-keys.ts +22 -0
  29. package/src/resource-types.test.ts +79 -0
  30. package/src/router-provider.test.ts +71 -0
  31. package/src/router-provider.ts +100 -0
  32. package/src/router.ts +28 -4
  33. package/src/routing-hooks.svelte.ts +99 -0
  34. package/src/table-hooks.svelte.ts +178 -0
  35. package/src/toast.svelte.ts +8 -4
  36. package/src/types.ts +124 -47
  37. package/src/unsaved-changes.svelte.ts +62 -0
  38. package/src/url-sync.ts +15 -0
  39. package/src/useParsed.ts +110 -0
  40. package/src/useStepsForm.svelte.ts +71 -0
  41. package/src/utility-hooks.svelte.ts +161 -0
package/src/live.ts CHANGED
@@ -1,10 +1,10 @@
1
- // LiveProvider — Real-time subscription interface + useLive hook
1
+ // LiveProvider — Real-time subscription interface + hooks
2
2
 
3
3
  import { useQueryClient } from '@tanstack/svelte-query';
4
4
 
5
- export interface LiveProvider {
6
- subscribe(params: { resource: string; callback: (event: LiveEvent) => void }): () => void;
7
- }
5
+ // ─── Types ──────────────────────────────────────────────────────
6
+
7
+ export type LiveMode = 'auto' | 'manual' | 'off';
8
8
 
9
9
  export interface LiveEvent {
10
10
  type: 'INSERT' | 'UPDATE' | 'DELETE';
@@ -12,17 +12,69 @@ export interface LiveEvent {
12
12
  payload: Record<string, unknown>;
13
13
  }
14
14
 
15
- // Hook to auto-invalidate queries on real-time changes
16
- export function useLive(liveProvider: LiveProvider, resource: string): void {
15
+ export interface LiveProvider {
16
+ subscribe(params: { resource: string; callback: (event: LiveEvent) => void }): () => void;
17
+ unsubscribe?(params: { resource: string }): void;
18
+ publish?(event: LiveEvent): void;
19
+ }
20
+
21
+ // ─── useLive — auto-invalidate queries on real-time events ──────
22
+
23
+ export function useLive(
24
+ liveProvider: LiveProvider,
25
+ resource: string,
26
+ options?: { liveMode?: LiveMode; onLiveEvent?: (event: LiveEvent) => void }
27
+ ): void {
17
28
  const queryClient = useQueryClient();
29
+ const liveMode = options?.liveMode ?? 'auto';
30
+
31
+ if (liveMode === 'off') return;
18
32
 
19
33
  $effect(() => {
20
34
  const unsubscribe = liveProvider.subscribe({
21
35
  resource,
22
- callback: () => {
23
- queryClient.invalidateQueries({ queryKey: [resource] });
36
+ callback: (event) => {
37
+ options?.onLiveEvent?.(event);
38
+ if (liveMode === 'auto') {
39
+ queryClient.invalidateQueries({ queryKey: [resource] });
40
+ }
24
41
  },
25
42
  });
26
43
  return unsubscribe;
27
44
  });
28
45
  }
46
+
47
+ // ─── useSubscription — manual channel subscription ──────────────
48
+
49
+ interface UseSubscriptionOptions {
50
+ resource: string;
51
+ liveProvider: LiveProvider;
52
+ onLiveEvent: (event: LiveEvent) => void;
53
+ enabled?: boolean;
54
+ }
55
+
56
+ export function useSubscription(options: UseSubscriptionOptions): void {
57
+ const { resource, liveProvider, onLiveEvent, enabled = true } = options;
58
+
59
+ if (!enabled) return;
60
+
61
+ $effect(() => {
62
+ const unsubscribe = liveProvider.subscribe({
63
+ resource,
64
+ callback: onLiveEvent,
65
+ });
66
+ return unsubscribe;
67
+ });
68
+ }
69
+
70
+ // ─── usePublish — publish custom events ─────────────────────────
71
+
72
+ export function usePublish(liveProvider: LiveProvider) {
73
+ return (event: LiveEvent) => {
74
+ if (liveProvider.publish) {
75
+ liveProvider.publish(event);
76
+ } else {
77
+ console.warn('[svadmin] LiveProvider.publish() not implemented');
78
+ }
79
+ };
80
+ }
@@ -0,0 +1,301 @@
1
+ import { createMutation, useQueryClient } from '@tanstack/svelte-query';
2
+ import { getAdminOptions } from './options';
3
+ import { getDataProviderForResource } from './context';
4
+ import { useParsed } from './useParsed';
5
+ import { audit } from './audit';
6
+ import { UndoError } from './types';
7
+ import type { BaseRecord, HttpError, CreateParams, UpdateParams, DeleteParams, KnownResources } from './types';
8
+ import { createOvertimeTracker, fireSuccessNotification, fireErrorNotification } from './hook-utils.svelte';
9
+ import type { NotificationConfig, OvertimeOptions } from './hook-utils.svelte';
10
+ import { toast } from './toast.svelte';
11
+
12
+ // ─── useCreate ─────────────────────────────────────────────────
13
+
14
+ export interface UseCreateOptions {
15
+ resource?: KnownResources;
16
+ mutationOptions?: Record<string, unknown>;
17
+ overtimeOptions?: OvertimeOptions;
18
+ }
19
+
20
+ export interface UseCreateMutateParams<TVariables> {
21
+ resource?: KnownResources;
22
+ variables: TVariables;
23
+ successNotification?: NotificationConfig;
24
+ errorNotification?: NotificationConfig;
25
+ invalidates?: string[] | false;
26
+ meta?: Record<string, unknown>;
27
+ dataProviderName?: string;
28
+ }
29
+
30
+ export function useCreate<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: UseCreateOptions = {}) {
31
+ const parsed = useParsed();
32
+ const defaultResource = options.resource ?? parsed.resource ?? '';
33
+ const adminOptions = getAdminOptions();
34
+ const queryClient = useQueryClient();
35
+
36
+ let isMutating = $state(false);
37
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
38
+
39
+ const mutation = createMutation<
40
+ { data: TData },
41
+ TError,
42
+ UseCreateMutateParams<TVariables>
43
+ >(() => ({
44
+ mutationFn: async (params) => {
45
+ isMutating = true;
46
+ const resName = params.resource ?? defaultResource;
47
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
48
+ try {
49
+ return await provider.create<TData, TVariables>({
50
+ resource: resName,
51
+ variables: params.variables,
52
+ meta: params.meta,
53
+ });
54
+ } finally {
55
+ isMutating = false;
56
+ }
57
+ },
58
+ onSuccess: (data, params) => {
59
+ const resName = params.resource ?? defaultResource;
60
+ if (params.invalidates !== false) {
61
+ queryClient.invalidateQueries({ queryKey: [resName] });
62
+ }
63
+ fireSuccessNotification(params.successNotification, 'Created successfully', data.data, params.variables, resName);
64
+ audit({ action: 'create', resource: resName, recordId: String(data.data.id) });
65
+ },
66
+ onError: (error, params) => {
67
+ fireErrorNotification(params.errorNotification, 'Create failed', error);
68
+ },
69
+ }));
70
+
71
+ return { mutation, get overtime() { return overtime; } };
72
+ }
73
+
74
+ // ─── useUpdate ─────────────────────────────────────────────────
75
+
76
+ export interface UseUpdateOptions {
77
+ resource?: KnownResources;
78
+ id?: string | number;
79
+ mutationMode?: 'pessimistic' | 'optimistic' | 'undoable';
80
+ undoableTimeout?: number;
81
+ mutationOptions?: Record<string, unknown>;
82
+ overtimeOptions?: OvertimeOptions;
83
+ }
84
+
85
+ export interface UseUpdateMutateParams<TVariables> {
86
+ resource?: KnownResources;
87
+ id?: string | number;
88
+ variables: TVariables;
89
+ successNotification?: NotificationConfig;
90
+ errorNotification?: NotificationConfig;
91
+ invalidates?: string[] | false;
92
+ meta?: Record<string, unknown>;
93
+ dataProviderName?: string;
94
+ onCancel?: () => void;
95
+ optimisticUpdateMap?: {
96
+ detail?: (previous: unknown, variables: TVariables, id?: string | number) => unknown;
97
+ list?: (previous: unknown, variables: TVariables, id?: string | number) => unknown;
98
+ };
99
+ }
100
+
101
+ export function useUpdate<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: UseUpdateOptions = {}) {
102
+ const parsed = useParsed();
103
+ const defaultResource = options.resource ?? parsed.resource ?? '';
104
+ const defaultId = options.id ?? parsed.id;
105
+ const adminOptions = getAdminOptions();
106
+ const queryClient = useQueryClient();
107
+ const mutationMode = options.mutationMode ?? adminOptions.mutationMode ?? 'pessimistic';
108
+ const undoableTimeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
109
+
110
+ let isMutating = $state(false);
111
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
112
+
113
+ const mutation = createMutation<
114
+ { data: TData },
115
+ TError,
116
+ UseUpdateMutateParams<TVariables>
117
+ >(() => ({
118
+ mutationFn: async (params) => {
119
+ isMutating = true;
120
+ const resName = params.resource ?? defaultResource;
121
+ const targetId = params.id ?? defaultId;
122
+ if (targetId == null) throw new Error('useUpdate requires an id');
123
+
124
+ if (mutationMode === 'undoable') {
125
+ await new Promise<void>((resolve, reject) => {
126
+ toast.undoable('Action can be undone', undoableTimeout, () => {
127
+ params.onCancel?.();
128
+ reject(new UndoError());
129
+ }, resolve);
130
+ });
131
+ }
132
+
133
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
134
+ try {
135
+ return await provider.update<TData, TVariables>({
136
+ resource: resName,
137
+ id: targetId,
138
+ variables: params.variables,
139
+ meta: params.meta,
140
+ });
141
+ } finally {
142
+ isMutating = false;
143
+ }
144
+ },
145
+ onMutate: async (params) => {
146
+ if (mutationMode === 'pessimistic') return undefined;
147
+ const resName = params.resource ?? defaultResource;
148
+ const targetId = params.id ?? defaultId;
149
+
150
+ await queryClient.cancelQueries({ queryKey: [resName] });
151
+ const previousDetail = queryClient.getQueryData([resName, 'one', targetId]);
152
+ const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
153
+
154
+ const updater = params.optimisticUpdateMap;
155
+ if (updater?.detail) {
156
+ queryClient.setQueryData([resName, 'one', targetId], (old: unknown) => updater.detail!(old, params.variables, targetId));
157
+ } else {
158
+ queryClient.setQueryData([resName, 'one', targetId], (old: Record<string, unknown> | undefined) => old ? { ...old, ...params.variables } : old);
159
+ }
160
+
161
+ if (updater?.list) {
162
+ queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => updater.list!(old, params.variables, targetId));
163
+ } else {
164
+ queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
165
+ if (!old || typeof old !== 'object' || !('data' in old)) return old;
166
+ const o = old as { data: Record<string, unknown>[] };
167
+ return { ...o, data: o.data.map((item) => String(item.id) === String(targetId) ? { ...item, ...params.variables } : item) };
168
+ });
169
+ }
170
+
171
+ return { previousDetail, previousList };
172
+ },
173
+ onSuccess: (data, params) => {
174
+ const resName = params.resource ?? defaultResource;
175
+ const targetId = params.id ?? defaultId;
176
+ if (params.invalidates !== false) {
177
+ queryClient.invalidateQueries({ queryKey: [resName] });
178
+ }
179
+ fireSuccessNotification(params.successNotification, 'Updated successfully', data.data, params.variables, resName);
180
+ audit({ action: 'update', resource: resName, recordId: String(targetId) });
181
+ },
182
+ onError: (error, params, context: unknown) => {
183
+ const resName = params.resource ?? defaultResource;
184
+ const targetId = params.id ?? defaultId;
185
+ const ctx = context as { previousDetail?: unknown; previousList?: [unknown, unknown][] } | undefined;
186
+ if (ctx?.previousDetail) queryClient.setQueryData([resName, 'one', targetId], ctx.previousDetail);
187
+ if (ctx?.previousList) {
188
+ ctx.previousList.forEach(([qk, data]) => queryClient.setQueryData(qk as string[], data));
189
+ }
190
+ if (error instanceof UndoError) return;
191
+ fireErrorNotification(params.errorNotification, 'Update failed', error);
192
+ },
193
+ }));
194
+
195
+ return { mutation, get overtime() { return overtime; } };
196
+ }
197
+
198
+ // ─── useDelete ─────────────────────────────────────────────────
199
+
200
+ export interface UseDeleteOptions {
201
+ resource?: KnownResources;
202
+ id?: string | number;
203
+ mutationMode?: 'pessimistic' | 'optimistic' | 'undoable';
204
+ undoableTimeout?: number;
205
+ mutationOptions?: Record<string, unknown>;
206
+ overtimeOptions?: OvertimeOptions;
207
+ }
208
+
209
+ export interface UseDeleteMutateParams<TVariables> {
210
+ resource?: KnownResources;
211
+ id?: string | number;
212
+ variables?: TVariables;
213
+ successNotification?: NotificationConfig;
214
+ errorNotification?: NotificationConfig;
215
+ invalidates?: string[] | false;
216
+ meta?: Record<string, unknown>;
217
+ dataProviderName?: string;
218
+ onCancel?: () => void;
219
+ }
220
+
221
+ export function useDelete<TData extends BaseRecord = BaseRecord, TError = HttpError, TVariables = Record<string, unknown>>(options: UseDeleteOptions = {}) {
222
+ const parsed = useParsed();
223
+ const defaultResource = options.resource ?? parsed.resource ?? '';
224
+ const defaultId = options.id ?? parsed.id;
225
+ const adminOptions = getAdminOptions();
226
+ const queryClient = useQueryClient();
227
+ const mutationMode = options.mutationMode ?? adminOptions.mutationMode ?? 'pessimistic';
228
+ const undoableTimeout = options.undoableTimeout ?? adminOptions.undoableTimeout ?? 5000;
229
+
230
+ let isMutating = $state(false);
231
+ const overtime = createOvertimeTracker(() => isMutating, options.overtimeOptions ?? adminOptions.overtime);
232
+
233
+ const mutation = createMutation<
234
+ { data: TData },
235
+ TError,
236
+ UseDeleteMutateParams<TVariables>
237
+ >(() => ({
238
+ mutationFn: async (params) => {
239
+ isMutating = true;
240
+ const resName = params.resource ?? defaultResource;
241
+ const targetId = params.id ?? defaultId;
242
+ if (targetId == null) throw new Error('useDelete requires an id');
243
+
244
+ if (mutationMode === 'undoable') {
245
+ await new Promise<void>((resolve, reject) => {
246
+ toast.undoable('Action can be undone', undoableTimeout, () => {
247
+ params.onCancel?.();
248
+ reject(new UndoError());
249
+ }, resolve);
250
+ });
251
+ }
252
+
253
+ const provider = getDataProviderForResource(resName, params.dataProviderName);
254
+ try {
255
+ return await provider.deleteOne<TData, TVariables>({
256
+ resource: resName,
257
+ id: targetId,
258
+ variables: params.variables,
259
+ meta: params.meta,
260
+ });
261
+ } finally {
262
+ isMutating = false;
263
+ }
264
+ },
265
+ onMutate: async (params) => {
266
+ if (mutationMode === 'pessimistic') return undefined;
267
+ const resName = params.resource ?? defaultResource;
268
+ const targetId = params.id ?? defaultId;
269
+
270
+ await queryClient.cancelQueries({ queryKey: [resName] });
271
+ const previousList = queryClient.getQueriesData({ queryKey: [resName, 'list'] });
272
+
273
+ queryClient.setQueriesData({ queryKey: [resName, 'list'] }, (old: unknown) => {
274
+ if (!old || typeof old !== 'object' || !('data' in old)) return old;
275
+ const o = old as { data: Record<string, unknown>[] };
276
+ return { ...o, data: o.data.filter((item) => String(item.id) !== String(targetId)) };
277
+ });
278
+
279
+ return { previousList };
280
+ },
281
+ onSuccess: (data, params) => {
282
+ const resName = params.resource ?? defaultResource;
283
+ const targetId = params.id ?? defaultId;
284
+ if (params.invalidates !== false) {
285
+ queryClient.invalidateQueries({ queryKey: [resName] });
286
+ }
287
+ fireSuccessNotification(params.successNotification, 'Deleted successfully', data.data, params.variables, resName);
288
+ audit({ action: 'delete', resource: resName, recordId: String(targetId) });
289
+ },
290
+ onError: (error, params, context: unknown) => {
291
+ const ctx = context as { previousList?: [unknown, unknown][] } | undefined;
292
+ if (ctx?.previousList) {
293
+ ctx.previousList.forEach(([qk, data]) => queryClient.setQueryData(qk as string[], data));
294
+ }
295
+ if (error instanceof UndoError) return;
296
+ fireErrorNotification(params.errorNotification, 'Delete failed', error);
297
+ },
298
+ }));
299
+
300
+ return { mutation, get overtime() { return overtime; } };
301
+ }
package/src/options.ts ADDED
@@ -0,0 +1,95 @@
1
+ // Global admin options — centralized configuration
2
+ // Provides defaults that individual hooks can override
3
+
4
+ import { setContext, getContext } from 'svelte';
5
+ import type { MutationMode } from './types';
6
+
7
+ export type InvalidateScope = 'all' | 'resourceAll' | 'detail' | 'list' | false | string[];
8
+
9
+ const OPTIONS_KEY = Symbol('admin-options');
10
+
11
+ export interface TextTransformers {
12
+ humanize: (text: string) => string;
13
+ plural: (text: string) => string;
14
+ singular: (text: string) => string;
15
+ }
16
+
17
+ export interface OvertimeConfig {
18
+ enabled?: boolean;
19
+ interval?: number;
20
+ onInterval?: (elapsedInterval: number, context?: { resource?: string; action?: string; id?: string | number }) => void;
21
+ }
22
+
23
+ export interface AdminOptions {
24
+ mutationMode?: MutationMode;
25
+ warnWhenUnsavedChanges?: boolean;
26
+ syncWithLocation?: boolean;
27
+ liveMode?: 'auto' | 'manual' | 'off';
28
+ disableServerSideValidation?: boolean;
29
+ undoableTimeout?: number;
30
+ // v0.2.26 additions
31
+ textTransformers?: Partial<TextTransformers>;
32
+ redirect?: {
33
+ afterCreate?: 'list' | 'edit' | 'show' | false;
34
+ afterEdit?: 'list' | 'edit' | 'show' | false;
35
+ afterClone?: 'list' | 'edit' | 'show' | false;
36
+ };
37
+ reactQuery?: {
38
+ staleTime?: number;
39
+ cacheTime?: number;
40
+ refetchOnWindowFocus?: boolean;
41
+ };
42
+ title?: {
43
+ text?: string;
44
+ icon?: string;
45
+ };
46
+ overtime?: OvertimeConfig;
47
+ breadcrumb?: false | 'default';
48
+ disableRouteChangeHandler?: boolean;
49
+ onLiveEvent?: (event: unknown) => void;
50
+ menuItems?: (items: { name: string; route: string; label: string; icon?: string; parentName?: string; order?: number }[]) => { name: string; route: string; label: string; icon?: string; parentName?: string; order?: number }[];
51
+ }
52
+
53
+ const defaultTextTransformers: TextTransformers = {
54
+ humanize: (text: string) => text.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase()),
55
+ plural: (text: string) => text.endsWith('s') ? text : text + 's',
56
+ singular: (text: string) => text.endsWith('s') ? text.slice(0, -1) : text,
57
+ };
58
+
59
+ const defaultOptions: AdminOptions = {
60
+ mutationMode: 'pessimistic',
61
+ warnWhenUnsavedChanges: false,
62
+ syncWithLocation: false,
63
+ liveMode: 'auto',
64
+ disableServerSideValidation: false,
65
+ undoableTimeout: 5000,
66
+ textTransformers: defaultTextTransformers,
67
+ redirect: { afterCreate: 'list', afterEdit: 'list', afterClone: 'list' },
68
+ breadcrumb: 'default',
69
+ disableRouteChangeHandler: false,
70
+ };
71
+
72
+ export function setAdminOptions(options: AdminOptions): void {
73
+ const merged = {
74
+ ...defaultOptions,
75
+ ...options,
76
+ textTransformers: { ...defaultTextTransformers, ...options.textTransformers },
77
+ };
78
+ setContext(OPTIONS_KEY, merged);
79
+ }
80
+
81
+ export function getAdminOptions(): AdminOptions {
82
+ try {
83
+ const opts = getContext<AdminOptions>(OPTIONS_KEY);
84
+ return opts ?? defaultOptions;
85
+ } catch {
86
+ return defaultOptions;
87
+ }
88
+ }
89
+
90
+ /** Get the resolved text transformers (always returns full set) */
91
+ export function getTextTransformers(): TextTransformers {
92
+ const opts = getAdminOptions();
93
+ return { ...defaultTextTransformers, ...opts.textTransformers };
94
+ }
95
+
@@ -0,0 +1,55 @@
1
+ // Unit tests for permissions module
2
+ import { describe, test, expect, beforeEach } from 'bun:test';
3
+ import { setAccessControl, canAccess, canAccessAsync } from './permissions';
4
+ import type { Action } from './permissions';
5
+
6
+ describe('permissions', () => {
7
+ beforeEach(() => {
8
+ // Reset to no access control (allow all)
9
+ setAccessControl((() => ({ can: true })) as Parameters<typeof setAccessControl>[0]);
10
+ });
11
+
12
+ test('canAccess returns { can: true } when no deny rules', () => {
13
+ const result = canAccess('posts', 'list' as Action);
14
+ expect(result.can).toBe(true);
15
+ });
16
+
17
+ test('canAccess respects deny rule', () => {
18
+ setAccessControl((_resource: string, action: Action) => {
19
+ if (action === 'delete') return { can: false, reason: 'Denied' };
20
+ return { can: true };
21
+ });
22
+ expect(canAccess('posts', 'delete')).toEqual({ can: false, reason: 'Denied' });
23
+ expect(canAccess('posts', 'list')).toEqual({ can: true });
24
+ });
25
+
26
+ test('canAccess returns { can: true } when async fn used sync', () => {
27
+ setAccessControl(async () => ({ can: false }));
28
+ // Should warn and default to { can: true }
29
+ const result = canAccess('posts', 'list');
30
+ expect(result.can).toBe(true);
31
+ });
32
+
33
+ test('canAccessAsync resolves with allowed status', async () => {
34
+ setAccessControl(async () => ({ can: true }));
35
+ const result = await canAccessAsync('posts', 'edit');
36
+ expect(result.can).toBe(true);
37
+ });
38
+
39
+ test('canAccessAsync resolves with denied status', async () => {
40
+ setAccessControl(async (_r: string, _a: Action) => ({ can: false, reason: 'No permission' }));
41
+ const result = await canAccessAsync('users', 'delete');
42
+ expect(result.can).toBe(false);
43
+ expect(result.reason).toBe('No permission');
44
+ });
45
+
46
+ test('resource-specific rules', () => {
47
+ setAccessControl((resource: string, action: Action) => {
48
+ if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
49
+ return { can: true };
50
+ });
51
+ expect(canAccess('users', 'delete').can).toBe(false);
52
+ expect(canAccess('users', 'list').can).toBe(true);
53
+ expect(canAccess('posts', 'delete').can).toBe(true);
54
+ });
55
+ });