@svadmin/core 0.25.1 → 0.25.3
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/package.json +6 -2
- package/src/index.ts +2 -0
- package/src/types.ts +21 -13
- package/src/README.md +0 -18
- package/src/auth-hooks.test.ts +0 -205
- package/src/chatProvider.test.ts +0 -388
- package/src/data-transfer.test.ts +0 -118
- package/src/form-hooks.test.svelte.ts +0 -83
- package/src/helpers.test.ts +0 -177
- package/src/http-error.test.ts +0 -85
- package/src/i18n.test.ts +0 -90
- package/src/inferencer-openapi.test.ts +0 -192
- package/src/inferencer.test.ts +0 -189
- package/src/live.test.ts +0 -134
- package/src/mutation-hooks.test.svelte.ts +0 -114
- package/src/permissions.test.ts +0 -136
- package/src/query-hooks.test.svelte.ts +0 -65
- package/src/reactive-hooks.test.svelte.ts +0 -26
- package/src/resource-types.test.ts +0 -79
- package/src/router-provider.test.ts +0 -82
- package/src/routing-hooks.test.svelte.ts +0 -55
- package/src/table-hooks.test.svelte.ts +0 -57
- package/src/task-hooks.test.svelte.ts +0 -129
package/src/inferencer.test.ts
DELETED
|
@@ -1,189 +0,0 @@
|
|
|
1
|
-
// Unit tests for Inferencer
|
|
2
|
-
import { describe, test, expect } from 'bun:test';
|
|
3
|
-
import { inferFieldType, inferResource } from './inferencer';
|
|
4
|
-
|
|
5
|
-
describe('inferFieldType', () => {
|
|
6
|
-
test('detects boolean', () => {
|
|
7
|
-
expect(inferFieldType('active', true)).toBe('boolean');
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
test('detects number', () => {
|
|
11
|
-
expect(inferFieldType('age', 25)).toBe('number');
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
test('detects email', () => {
|
|
15
|
-
expect(inferFieldType('email', 'user@example.com')).toBe('email');
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test('detects email from key name', () => {
|
|
19
|
-
expect(inferFieldType('user_email', 'test')).toBe('email');
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('detects URL', () => {
|
|
23
|
-
expect(inferFieldType('website', 'https://example.com')).toBe('url');
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('detects image URL', () => {
|
|
27
|
-
expect(inferFieldType('avatar', 'https://example.com/photo.jpg')).toBe('image');
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('detects image from key name', () => {
|
|
31
|
-
expect(inferFieldType('avatar', '/uploads/user.png')).toBe('image');
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test('detects date ISO string', () => {
|
|
35
|
-
expect(inferFieldType('created', '2024-01-15T10:30:00Z')).toBe('date');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
test('detects date from key name', () => {
|
|
39
|
-
expect(inferFieldType('created_at', 'some value')).toBe('date');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test('detects phone', () => {
|
|
43
|
-
expect(inferFieldType('phone', '+1 (555) 123-4567')).toBe('phone');
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
test('detects color hex', () => {
|
|
47
|
-
expect(inferFieldType('color', '#ff5533')).toBe('color');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('detects textarea for long strings', () => {
|
|
51
|
-
expect(inferFieldType('content', 'a'.repeat(250))).toBe('textarea');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('detects textarea from key name', () => {
|
|
55
|
-
expect(inferFieldType('description', 'short')).toBe('textarea');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
test('detects tags array', () => {
|
|
59
|
-
expect(inferFieldType('tags', ['svelte', 'admin'])).toBe('tags');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('detects images array', () => {
|
|
63
|
-
expect(inferFieldType('photos', ['https://x.com/a.png', 'https://x.com/b.jpg'])).toBe('images');
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test('detects json object', () => {
|
|
67
|
-
expect(inferFieldType('meta', { foo: 'bar' })).toBe('json');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('null defaults to text', () => {
|
|
71
|
-
expect(inferFieldType('unknown', null)).toBe('text');
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('plain string defaults to text', () => {
|
|
75
|
-
expect(inferFieldType('title', 'Hello World')).toBe('text');
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('inferResource', () => {
|
|
80
|
-
const sampleData = [
|
|
81
|
-
{
|
|
82
|
-
id: 1,
|
|
83
|
-
title: 'First Post',
|
|
84
|
-
content: 'A very long description that is more than two hundred characters. '.repeat(4),
|
|
85
|
-
author_id: 5,
|
|
86
|
-
status: 'published',
|
|
87
|
-
views: 100,
|
|
88
|
-
is_featured: true,
|
|
89
|
-
created_at: '2024-01-15T10:00:00Z',
|
|
90
|
-
email: 'author@blog.com',
|
|
91
|
-
tags: ['svelte', 'admin'],
|
|
92
|
-
},
|
|
93
|
-
{
|
|
94
|
-
id: 2,
|
|
95
|
-
title: 'Second Post',
|
|
96
|
-
content: 'Another long body text that exceeds the 200 char limit easily. '.repeat(5),
|
|
97
|
-
author_id: 3,
|
|
98
|
-
status: 'draft',
|
|
99
|
-
views: 42,
|
|
100
|
-
is_featured: false,
|
|
101
|
-
created_at: '2024-02-20T14:30:00Z',
|
|
102
|
-
email: 'editor@blog.com',
|
|
103
|
-
tags: ['react'],
|
|
104
|
-
},
|
|
105
|
-
];
|
|
106
|
-
|
|
107
|
-
test('returns correct resource name and label', () => {
|
|
108
|
-
const result = inferResource('posts', sampleData);
|
|
109
|
-
expect(result.resource.name).toBe('posts');
|
|
110
|
-
expect(result.resource.label).toBe('Posts');
|
|
111
|
-
});
|
|
112
|
-
|
|
113
|
-
test('infers correct number of fields', () => {
|
|
114
|
-
const result = inferResource('posts', sampleData);
|
|
115
|
-
expect(result.fields.length).toBe(10); // id, title, content, author_id, status, views, is_featured, created_at, email, tags
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
test('detects id as primary key', () => {
|
|
119
|
-
const result = inferResource('posts', sampleData);
|
|
120
|
-
const idField = result.fields.find(f => f.key === 'id');
|
|
121
|
-
expect(idField).toBeDefined();
|
|
122
|
-
expect(idField!.showInForm).toBe(false);
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
test('detects number type for views', () => {
|
|
126
|
-
const result = inferResource('posts', sampleData);
|
|
127
|
-
const viewsField = result.fields.find(f => f.key === 'views');
|
|
128
|
-
expect(viewsField!.type).toBe('number');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test('detects boolean type for is_featured', () => {
|
|
132
|
-
const result = inferResource('posts', sampleData);
|
|
133
|
-
const field = result.fields.find(f => f.key === 'is_featured');
|
|
134
|
-
expect(field!.type).toBe('boolean');
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
test('detects date for created_at', () => {
|
|
138
|
-
const result = inferResource('posts', sampleData);
|
|
139
|
-
const field = result.fields.find(f => f.key === 'created_at');
|
|
140
|
-
expect(field!.type).toBe('date');
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('detects relation for author_id', () => {
|
|
144
|
-
const result = inferResource('posts', sampleData);
|
|
145
|
-
const field = result.fields.find(f => f.key === 'author_id');
|
|
146
|
-
expect(field!.type).toBe('relation');
|
|
147
|
-
expect(field!.resource).toBe('authors');
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
test('detects email type', () => {
|
|
151
|
-
const result = inferResource('posts', sampleData);
|
|
152
|
-
const field = result.fields.find(f => f.key === 'email');
|
|
153
|
-
expect(field!.type).toBe('email');
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('detects tags array', () => {
|
|
157
|
-
const result = inferResource('posts', sampleData);
|
|
158
|
-
const field = result.fields.find(f => f.key === 'tags');
|
|
159
|
-
expect(field!.type).toBe('tags');
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('detects textarea for content', () => {
|
|
163
|
-
const result = inferResource('posts', sampleData);
|
|
164
|
-
const field = result.fields.find(f => f.key === 'content');
|
|
165
|
-
expect(field!.type).toBe('textarea');
|
|
166
|
-
expect(field!.showInList).toBe(false);
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
test('generates valid TypeScript code', () => {
|
|
170
|
-
const result = inferResource('posts', sampleData);
|
|
171
|
-
expect(result.code).toContain("name: 'posts'");
|
|
172
|
-
expect(result.code).toContain("import type { ResourceDefinition }");
|
|
173
|
-
expect(result.code).toContain("export const postsResource");
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
test('handles empty data gracefully', () => {
|
|
177
|
-
const result = inferResource('empty', []);
|
|
178
|
-
expect(result.fields.length).toBe(0);
|
|
179
|
-
expect(result.code).toContain('No data available');
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test('custom primaryKey', () => {
|
|
183
|
-
const data = [{ _id: 'abc', name: 'Test' }];
|
|
184
|
-
const result = inferResource('items', data, { primaryKey: '_id' });
|
|
185
|
-
expect(result.resource.primaryKey).toBe('_id');
|
|
186
|
-
const pkField = result.fields.find(f => f.key === '_id');
|
|
187
|
-
expect(pkField!.showInForm).toBe(false);
|
|
188
|
-
});
|
|
189
|
-
});
|
package/src/live.test.ts
DELETED
|
@@ -1,134 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* LiveProvider unit tests
|
|
3
|
-
* Tests the subscription management logic without requiring actual WebSocket/SSE connections.
|
|
4
|
-
*/
|
|
5
|
-
import { describe, test, expect, mock } from 'bun:test';
|
|
6
|
-
import type { LiveProvider, LiveEvent } from './live.svelte';
|
|
7
|
-
|
|
8
|
-
// Helper: create a minimal in-memory LiveProvider for testing subscription logic
|
|
9
|
-
function createMockLiveProvider(): LiveProvider & { emit: (event: LiveEvent) => void } {
|
|
10
|
-
type Callback = (event: LiveEvent) => void;
|
|
11
|
-
const subscribers = new Map<string, Set<Callback>>();
|
|
12
|
-
|
|
13
|
-
return {
|
|
14
|
-
subscribe({ resource, callback }) {
|
|
15
|
-
if (!subscribers.has(resource)) {
|
|
16
|
-
subscribers.set(resource, new Set());
|
|
17
|
-
}
|
|
18
|
-
subscribers.get(resource)!.add(callback);
|
|
19
|
-
|
|
20
|
-
return () => {
|
|
21
|
-
const callbacks = subscribers.get(resource);
|
|
22
|
-
if (callbacks) {
|
|
23
|
-
callbacks.delete(callback);
|
|
24
|
-
if (callbacks.size === 0) subscribers.delete(resource);
|
|
25
|
-
}
|
|
26
|
-
};
|
|
27
|
-
},
|
|
28
|
-
|
|
29
|
-
emit(event: LiveEvent) {
|
|
30
|
-
const callbacks = subscribers.get(event.resource);
|
|
31
|
-
if (callbacks) {
|
|
32
|
-
for (const cb of callbacks) cb(event);
|
|
33
|
-
}
|
|
34
|
-
// Wildcard
|
|
35
|
-
const wildcardCallbacks = subscribers.get('*');
|
|
36
|
-
if (wildcardCallbacks) {
|
|
37
|
-
for (const cb of wildcardCallbacks) cb(event);
|
|
38
|
-
}
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('LiveProvider subscription', () => {
|
|
44
|
-
test('subscribe receives events for correct resource', () => {
|
|
45
|
-
const provider = createMockLiveProvider();
|
|
46
|
-
const received: LiveEvent[] = [];
|
|
47
|
-
|
|
48
|
-
provider.subscribe({
|
|
49
|
-
resource: 'posts',
|
|
50
|
-
callback: (event) => received.push(event),
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
provider.emit({ type: 'INSERT', resource: 'posts', payload: { id: 1 } });
|
|
54
|
-
provider.emit({ type: 'UPDATE', resource: 'comments', payload: { id: 2 } });
|
|
55
|
-
|
|
56
|
-
expect(received.length).toBe(1);
|
|
57
|
-
expect(received[0].type).toBe('INSERT');
|
|
58
|
-
expect(received[0].resource).toBe('posts');
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('wildcard subscriber receives all events', () => {
|
|
62
|
-
const provider = createMockLiveProvider();
|
|
63
|
-
const received: LiveEvent[] = [];
|
|
64
|
-
|
|
65
|
-
provider.subscribe({
|
|
66
|
-
resource: '*',
|
|
67
|
-
callback: (event) => received.push(event),
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
|
|
71
|
-
provider.emit({ type: 'DELETE', resource: 'users', payload: {} });
|
|
72
|
-
|
|
73
|
-
expect(received.length).toBe(2);
|
|
74
|
-
expect(received[0].resource).toBe('posts');
|
|
75
|
-
expect(received[1].resource).toBe('users');
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('unsubscribe stops receiving events', () => {
|
|
79
|
-
const provider = createMockLiveProvider();
|
|
80
|
-
const received: LiveEvent[] = [];
|
|
81
|
-
|
|
82
|
-
const unsub = provider.subscribe({
|
|
83
|
-
resource: 'posts',
|
|
84
|
-
callback: (event) => received.push(event),
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
|
|
88
|
-
expect(received.length).toBe(1);
|
|
89
|
-
|
|
90
|
-
unsub();
|
|
91
|
-
|
|
92
|
-
provider.emit({ type: 'UPDATE', resource: 'posts', payload: {} });
|
|
93
|
-
expect(received.length).toBe(1); // No new events after unsub
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('multiple subscribers for same resource', () => {
|
|
97
|
-
const provider = createMockLiveProvider();
|
|
98
|
-
const received1: LiveEvent[] = [];
|
|
99
|
-
const received2: LiveEvent[] = [];
|
|
100
|
-
|
|
101
|
-
provider.subscribe({ resource: 'posts', callback: (e) => received1.push(e) });
|
|
102
|
-
provider.subscribe({ resource: 'posts', callback: (e) => received2.push(e) });
|
|
103
|
-
|
|
104
|
-
provider.emit({ type: 'INSERT', resource: 'posts', payload: { id: 1 } });
|
|
105
|
-
|
|
106
|
-
expect(received1.length).toBe(1);
|
|
107
|
-
expect(received2.length).toBe(1);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test('LiveEvent types are correct', () => {
|
|
111
|
-
const insertEvent: LiveEvent = { type: 'INSERT', resource: 'posts', payload: { title: 'New' } };
|
|
112
|
-
const updateEvent: LiveEvent = { type: 'UPDATE', resource: 'posts', payload: { title: 'Updated' } };
|
|
113
|
-
const deleteEvent: LiveEvent = { type: 'DELETE', resource: 'posts', payload: { id: 1 } };
|
|
114
|
-
|
|
115
|
-
expect(insertEvent.type).toBe('INSERT');
|
|
116
|
-
expect(updateEvent.type).toBe('UPDATE');
|
|
117
|
-
expect(deleteEvent.type).toBe('DELETE');
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
test('unsubscribing one does not affect others', () => {
|
|
121
|
-
const provider = createMockLiveProvider();
|
|
122
|
-
const received1: LiveEvent[] = [];
|
|
123
|
-
const received2: LiveEvent[] = [];
|
|
124
|
-
|
|
125
|
-
const unsub1 = provider.subscribe({ resource: 'posts', callback: (e) => received1.push(e) });
|
|
126
|
-
provider.subscribe({ resource: 'posts', callback: (e) => received2.push(e) });
|
|
127
|
-
|
|
128
|
-
unsub1();
|
|
129
|
-
provider.emit({ type: 'INSERT', resource: 'posts', payload: {} });
|
|
130
|
-
|
|
131
|
-
expect(received1.length).toBe(0);
|
|
132
|
-
expect(received2.length).toBe(1);
|
|
133
|
-
});
|
|
134
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { useCreate, useUpdate, invalidateByScopes } from './mutation-hooks.svelte';
|
|
3
|
-
import { flushSync } from 'svelte';
|
|
4
|
-
import { QueryClient } from '@tanstack/svelte-query';
|
|
5
|
-
|
|
6
|
-
vi.mock('./context.svelte', () => ({
|
|
7
|
-
useDataProvider: () => ({
|
|
8
|
-
getOne: vi.fn(),
|
|
9
|
-
getList: vi.fn(),
|
|
10
|
-
create: vi.fn().mockResolvedValue({ data: { id: 1 } }),
|
|
11
|
-
update: vi.fn().mockResolvedValue({ data: { id: 1 } })
|
|
12
|
-
}),
|
|
13
|
-
useResource: () => ({ name: 'posts' }),
|
|
14
|
-
getResource: (name: string) => ({ name, primaryKey: 'id' }),
|
|
15
|
-
useRouterContext: () => ({ navigate: vi.fn() }),
|
|
16
|
-
useTranslate: () => (key: string) => key,
|
|
17
|
-
useNotification: () => ({ open: vi.fn(), close: vi.fn() }),
|
|
18
|
-
getLiveProvider: () => undefined
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
|
22
|
-
const actual = await importOriginal();
|
|
23
|
-
return {
|
|
24
|
-
...actual as any,
|
|
25
|
-
useQueryClient: () => new QueryClient(),
|
|
26
|
-
createMutation: (opts: any) => ({
|
|
27
|
-
mutate: vi.fn((vars, callbacks) => {
|
|
28
|
-
if (opts.mutationFn) opts.mutationFn(vars);
|
|
29
|
-
if (callbacks?.onSuccess) callbacks.onSuccess({ data: {} });
|
|
30
|
-
}),
|
|
31
|
-
isPending: false
|
|
32
|
-
})
|
|
33
|
-
};
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
describe('useCreate & useUpdate - Headless Svelte 5 Compatibility', () => {
|
|
37
|
-
|
|
38
|
-
it('safely binds useCreate and allows mutations over proxies', () => {
|
|
39
|
-
let createMutation: ReturnType<typeof useCreate>;
|
|
40
|
-
|
|
41
|
-
const cleanup = $effect.root(() => {
|
|
42
|
-
createMutation = useCreate();
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
flushSync();
|
|
46
|
-
|
|
47
|
-
expect(createMutation!.mutation.mutate).toBeDefined();
|
|
48
|
-
expect(createMutation!.mutation.isPending).toBe(false);
|
|
49
|
-
|
|
50
|
-
cleanup();
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it('safely binds useUpdate and evaluates data updates', () => {
|
|
54
|
-
let updateMutation: ReturnType<typeof useUpdate>;
|
|
55
|
-
|
|
56
|
-
const cleanup = $effect.root(() => {
|
|
57
|
-
updateMutation = useUpdate();
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
flushSync();
|
|
61
|
-
|
|
62
|
-
expect(updateMutation!.mutation.mutate).toBeDefined();
|
|
63
|
-
expect(updateMutation!.mutation.isPending).toBe(false);
|
|
64
|
-
|
|
65
|
-
cleanup();
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
describe('mutation-hooks', () => {
|
|
69
|
-
|
|
70
|
-
describe('invalidateByScopes - Multi-Provider Cache Isolation', () => {
|
|
71
|
-
it('filters perfectly by dataProviderName when passed', () => {
|
|
72
|
-
const queryClient = new QueryClient();
|
|
73
|
-
queryClient.invalidateQueries = vi.fn(async () => {});
|
|
74
|
-
|
|
75
|
-
const resource = 'posts';
|
|
76
|
-
const providerName = 'secondaryProvider';
|
|
77
|
-
|
|
78
|
-
// Act
|
|
79
|
-
invalidateByScopes(queryClient, resource, ['list'], ['list'], undefined, providerName);
|
|
80
|
-
|
|
81
|
-
expect(queryClient.invalidateQueries).toHaveBeenCalled();
|
|
82
|
-
|
|
83
|
-
// Assert predicate captures DataProvider scopes
|
|
84
|
-
const calls = (queryClient.invalidateQueries as ReturnType<typeof vi.fn>).mock.calls;
|
|
85
|
-
|
|
86
|
-
// Using arbitrary mock objects simulating Query keys
|
|
87
|
-
const predicateFn = calls[0][0].predicate;
|
|
88
|
-
|
|
89
|
-
const shouldPass = predicateFn({ queryKey: ['secondaryProvider', 'posts', 'list'] });
|
|
90
|
-
const shouldFail = predicateFn({ queryKey: ['default', 'posts', 'list'] });
|
|
91
|
-
const shouldFailResource = predicateFn({ queryKey: ['secondaryProvider', 'users', 'list'] });
|
|
92
|
-
|
|
93
|
-
expect(shouldPass).toBe(true);
|
|
94
|
-
expect(shouldFail).toBe(false);
|
|
95
|
-
expect(shouldFailResource).toBe(false);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it('falls back to all providers when NO provider is specified', () => {
|
|
99
|
-
const queryClient = new QueryClient();
|
|
100
|
-
queryClient.invalidateQueries = vi.fn(async () => {});
|
|
101
|
-
|
|
102
|
-
// Act without providerName
|
|
103
|
-
invalidateByScopes(queryClient, 'posts', ['list'], ['list']);
|
|
104
|
-
|
|
105
|
-
const predicateFn = (queryClient.invalidateQueries as ReturnType<typeof vi.fn>).mock.calls[0][0].predicate;
|
|
106
|
-
|
|
107
|
-
const pass1 = predicateFn({ queryKey: ['default', 'posts', 'list'] });
|
|
108
|
-
const pass2 = predicateFn({ queryKey: ['custom', 'posts', 'list'] });
|
|
109
|
-
expect(pass1).toBe(true);
|
|
110
|
-
expect(pass2).toBe(true);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
});
|
package/src/permissions.test.ts
DELETED
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { describe, test, expect } from 'bun:test';
|
|
2
|
-
import type { CanParams, CanResult, Action, AccessControlProvider } from './permissions.svelte';
|
|
3
|
-
|
|
4
|
-
describe('permissions', () => {
|
|
5
|
-
test('canAccessAsync respects deny rule', async () => {
|
|
6
|
-
const provider: AccessControlProvider = {
|
|
7
|
-
can: async (p) => {
|
|
8
|
-
const { action } = p as CanParams;
|
|
9
|
-
if (action === 'delete') return { can: false, reason: 'Denied' };
|
|
10
|
-
return { can: true };
|
|
11
|
-
},
|
|
12
|
-
};
|
|
13
|
-
expect(await provider.can({ resource: 'posts', action: 'delete' })).toEqual({ can: false, reason: 'Denied' });
|
|
14
|
-
expect((await provider.can({ resource: 'posts', action: 'list' })) as CanResult).toEqual({ can: true });
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
test('canAccessAsync resolves with allowed status', async () => {
|
|
18
|
-
const provider: AccessControlProvider = {
|
|
19
|
-
can: async (params) => {
|
|
20
|
-
expect((params as CanParams).params?.published).toBe(true);
|
|
21
|
-
return { can: true };
|
|
22
|
-
},
|
|
23
|
-
};
|
|
24
|
-
const result = await provider.can({ resource: 'posts', action: 'edit', params: { published: true } });
|
|
25
|
-
expect((result as CanResult).can).toBe(true);
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
test('canAccessAsync resolves with denied status', async () => {
|
|
29
|
-
const provider: AccessControlProvider = {
|
|
30
|
-
can: async () => ({ can: false, reason: 'No permission' }),
|
|
31
|
-
};
|
|
32
|
-
const result = await provider.can({ resource: 'users', action: 'delete' });
|
|
33
|
-
expect((result as CanResult).can).toBe(false);
|
|
34
|
-
expect((result as CanResult).reason).toBe('No permission');
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test('resource-specific rules', async () => {
|
|
38
|
-
const provider: AccessControlProvider = {
|
|
39
|
-
can: async (p) => {
|
|
40
|
-
const { resource, action } = p as CanParams;
|
|
41
|
-
if (resource === 'users' && action === 'delete') return { can: false, reason: 'Cannot delete users' };
|
|
42
|
-
return { can: true };
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
expect((await provider.can({ resource: 'users', action: 'delete' }) as CanResult).can).toBe(false);
|
|
46
|
-
expect((await provider.can({ resource: 'users', action: 'list' }) as CanResult).can).toBe(true);
|
|
47
|
-
expect((await provider.can({ resource: 'posts', action: 'delete' }) as CanResult).can).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('params.id is passed through', async () => {
|
|
51
|
-
const provider: AccessControlProvider = {
|
|
52
|
-
can: async (p) => {
|
|
53
|
-
const { params } = p as CanParams;
|
|
54
|
-
if (params?.id === 42) return { can: false, reason: 'Record locked' };
|
|
55
|
-
return { can: true };
|
|
56
|
-
},
|
|
57
|
-
};
|
|
58
|
-
const result = await provider.can({ resource: 'posts', action: 'edit', params: { id: 42 } });
|
|
59
|
-
expect((result as CanResult).can).toBe(false);
|
|
60
|
-
expect((result as CanResult).reason).toBe('Record locked');
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('extended action types work', async () => {
|
|
64
|
-
const provider: AccessControlProvider = {
|
|
65
|
-
can: async (p) => {
|
|
66
|
-
const { action } = p as CanParams;
|
|
67
|
-
if (action === 'show') return { can: false, reason: 'No show' };
|
|
68
|
-
if (action === 'field') return { can: false, reason: 'No field' };
|
|
69
|
-
return { can: true };
|
|
70
|
-
},
|
|
71
|
-
};
|
|
72
|
-
expect(((await provider.can({ resource: 'posts', action: 'show' })) as CanResult).can).toBe(false);
|
|
73
|
-
expect(((await provider.can({ resource: 'posts', action: 'field' })) as CanResult).can).toBe(false);
|
|
74
|
-
expect(((await provider.can({ resource: 'posts', action: 'list' })) as CanResult).can).toBe(true);
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test('getAccessControlOptions returns provider options', () => {
|
|
78
|
-
const provider: AccessControlProvider = {
|
|
79
|
-
can: async () => ({ can: true }),
|
|
80
|
-
options: {
|
|
81
|
-
buttons: { enableAccessControl: true, hideIfUnauthorized: true },
|
|
82
|
-
},
|
|
83
|
-
};
|
|
84
|
-
expect(provider.options?.buttons?.enableAccessControl).toBe(true);
|
|
85
|
-
expect(provider.options?.buttons?.hideIfUnauthorized).toBe(true);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
test('getAccessControlOptions returns {} when no options', () => {
|
|
89
|
-
const provider: AccessControlProvider = {
|
|
90
|
-
can: async () => ({ can: true }),
|
|
91
|
-
};
|
|
92
|
-
expect(provider.options).toBeUndefined();
|
|
93
|
-
});
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
describe('CASL adapter', () => {
|
|
97
|
-
test('createCaslAccessControl works', async () => {
|
|
98
|
-
const { createCaslAccessControl } = await import('./adapters/casl');
|
|
99
|
-
|
|
100
|
-
const ability = {
|
|
101
|
-
can: (action: string, subject: string) => !(action === 'delete' && subject === 'users'),
|
|
102
|
-
cannot: (action: string, subject: string) => action === 'delete' && subject === 'users',
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const provider = createCaslAccessControl(ability);
|
|
106
|
-
|
|
107
|
-
const allowed = await provider.can({ resource: 'posts', action: 'edit' }) as CanResult;
|
|
108
|
-
expect(allowed.can).toBe(true);
|
|
109
|
-
|
|
110
|
-
const denied = await provider.can({ resource: 'users', action: 'delete' }) as CanResult;
|
|
111
|
-
expect(denied.can).toBe(false);
|
|
112
|
-
expect(denied.reason).toContain('Cannot "delete" on "users"');
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('Casbin adapter', () => {
|
|
117
|
-
test('createCasbinAccessControl works', async () => {
|
|
118
|
-
const { createCasbinAccessControl } = await import('./adapters/casbin');
|
|
119
|
-
|
|
120
|
-
const enforcer = {
|
|
121
|
-
enforce: async (sub: string, obj: string, act: string) =>
|
|
122
|
-
!(sub === 'alice' && obj === 'users' && act === 'delete'),
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const provider = createCasbinAccessControl(enforcer, {
|
|
126
|
-
getUser: () => 'alice',
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
const allowed = await provider.can({ resource: 'posts', action: 'edit' }) as CanResult;
|
|
130
|
-
expect(allowed.can).toBe(true);
|
|
131
|
-
|
|
132
|
-
const denied = await provider.can({ resource: 'users', action: 'delete' }) as CanResult;
|
|
133
|
-
expect(denied.can).toBe(false);
|
|
134
|
-
expect(denied.reason).toContain('alice');
|
|
135
|
-
});
|
|
136
|
-
});
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { useList, useOne } from './query-hooks.svelte';
|
|
3
|
-
import { flushSync } from 'svelte';
|
|
4
|
-
import { QueryClient } from '@tanstack/svelte-query';
|
|
5
|
-
|
|
6
|
-
vi.mock('./context.svelte', () => ({
|
|
7
|
-
useDataProvider: () => ({
|
|
8
|
-
getOne: vi.fn(),
|
|
9
|
-
getList: vi.fn()
|
|
10
|
-
}),
|
|
11
|
-
useResource: () => ({ name: 'posts' }),
|
|
12
|
-
getResource: (name: string) => ({ name, primaryKey: 'id' }),
|
|
13
|
-
useRouterContext: () => ({ navigate: vi.fn() }),
|
|
14
|
-
useTranslate: () => (key: string) => key,
|
|
15
|
-
useNotification: () => ({ open: vi.fn(), close: vi.fn() }),
|
|
16
|
-
getLiveProvider: () => undefined
|
|
17
|
-
}));
|
|
18
|
-
|
|
19
|
-
vi.mock('@tanstack/svelte-query', async (importOriginal) => {
|
|
20
|
-
const actual = await importOriginal();
|
|
21
|
-
return {
|
|
22
|
-
...actual as any,
|
|
23
|
-
useQueryClient: () => new QueryClient(),
|
|
24
|
-
createQuery: () => ({
|
|
25
|
-
data: { data: [{ id: 1, title: 'One' }], title: 'One' }, // Unified mock to please both array and direct object assertions
|
|
26
|
-
isPending: false,
|
|
27
|
-
isFetching: false,
|
|
28
|
-
error: null
|
|
29
|
-
})
|
|
30
|
-
};
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
describe('useList & useOne - Headless Svelte 5 Compatibility', () => {
|
|
34
|
-
|
|
35
|
-
it('safely binds useList query state without headless Svelte destruction', () => {
|
|
36
|
-
let listQuery: ReturnType<typeof useList>;
|
|
37
|
-
|
|
38
|
-
const cleanup = $effect.root(() => {
|
|
39
|
-
listQuery = useList({ resource: 'posts' });
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
flushSync();
|
|
43
|
-
|
|
44
|
-
expect(listQuery!.data).toBeDefined();
|
|
45
|
-
expect(listQuery!.data!.data[0].title).toBe('One');
|
|
46
|
-
expect(listQuery!.isPending).toBe(false);
|
|
47
|
-
|
|
48
|
-
cleanup();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('safely binds useOne query state without headless component mounting', () => {
|
|
52
|
-
let oneQuery: ReturnType<typeof useOne>;
|
|
53
|
-
|
|
54
|
-
const cleanup = $effect.root(() => {
|
|
55
|
-
oneQuery = useOne({ resource: 'posts', id: 1 });
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
flushSync();
|
|
59
|
-
|
|
60
|
-
expect(oneQuery!.data).toBeDefined();
|
|
61
|
-
expect((oneQuery!.data!.data as any).title).toBe('One');
|
|
62
|
-
|
|
63
|
-
cleanup();
|
|
64
|
-
});
|
|
65
|
-
});
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { useThemedLayoutContext } from './hooks.svelte';
|
|
3
|
-
|
|
4
|
-
describe('Svelte 5 Reactive Runes Compatibility', () => {
|
|
5
|
-
|
|
6
|
-
it('verifies that sidebarCollapsed is correctly synchronized out of component lifecycle', () => {
|
|
7
|
-
let observedValue = false;
|
|
8
|
-
let layout: ReturnType<typeof useThemedLayoutContext>;
|
|
9
|
-
|
|
10
|
-
const cleanup = $effect.root(() => {
|
|
11
|
-
layout = useThemedLayoutContext();
|
|
12
|
-
|
|
13
|
-
$effect(() => {
|
|
14
|
-
observedValue = layout.sidebarCollapsed;
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
expect(observedValue).toBe(false);
|
|
19
|
-
|
|
20
|
-
layout!.toggleSidebar();
|
|
21
|
-
|
|
22
|
-
expect(layout!.sidebarCollapsed).toBe(true);
|
|
23
|
-
|
|
24
|
-
cleanup();
|
|
25
|
-
});
|
|
26
|
-
});
|