@pyreon/feature 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +382 -0
- package/lib/analysis/index.js.html +5406 -0
- package/lib/index.js +464 -0
- package/lib/index.js.map +1 -0
- package/lib/types/index.d.ts +501 -0
- package/lib/types/index.d.ts.map +1 -0
- package/lib/types/index2.d.ts +259 -0
- package/lib/types/index2.d.ts.map +1 -0
- package/package.json +60 -0
- package/src/define-feature.ts +420 -0
- package/src/index.ts +19 -0
- package/src/schema.ts +334 -0
- package/src/tests/feature.test.ts +1416 -0
- package/src/types.ts +159 -0
|
@@ -0,0 +1,1416 @@
|
|
|
1
|
+
import { h } from '@pyreon/core'
|
|
2
|
+
import { signal } from '@pyreon/reactivity'
|
|
3
|
+
import { mount } from '@pyreon/runtime-dom'
|
|
4
|
+
import { QueryClient, QueryClientProvider } from '@pyreon/query'
|
|
5
|
+
import { resetAllStores } from '@pyreon/store'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { defineFeature } from '../define-feature'
|
|
8
|
+
import {
|
|
9
|
+
extractFields,
|
|
10
|
+
defaultInitialValues,
|
|
11
|
+
reference,
|
|
12
|
+
isReference,
|
|
13
|
+
} from '../schema'
|
|
14
|
+
|
|
15
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function mountWith<T>(
|
|
18
|
+
client: QueryClient,
|
|
19
|
+
fn: () => T,
|
|
20
|
+
): { result: T; unmount: () => void } {
|
|
21
|
+
let result: T | undefined
|
|
22
|
+
const el = document.createElement('div')
|
|
23
|
+
document.body.appendChild(el)
|
|
24
|
+
const unmount = mount(
|
|
25
|
+
h(
|
|
26
|
+
QueryClientProvider as any,
|
|
27
|
+
{ client },
|
|
28
|
+
h(() => {
|
|
29
|
+
result = fn()
|
|
30
|
+
return null
|
|
31
|
+
}, null),
|
|
32
|
+
),
|
|
33
|
+
el,
|
|
34
|
+
)
|
|
35
|
+
return {
|
|
36
|
+
result: result!,
|
|
37
|
+
unmount: () => {
|
|
38
|
+
unmount()
|
|
39
|
+
el.remove()
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Schema ────────────────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
const userSchema = z.object({
|
|
47
|
+
name: z.string().min(2),
|
|
48
|
+
email: z.string().email(),
|
|
49
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
50
|
+
age: z.number().optional(),
|
|
51
|
+
active: z.boolean(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
type UserValues = z.infer<typeof userSchema>
|
|
55
|
+
|
|
56
|
+
// ─── Mock fetch ────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function createMockFetch(
|
|
59
|
+
responses: Record<string, { status?: number; body?: unknown }>,
|
|
60
|
+
) {
|
|
61
|
+
return async (
|
|
62
|
+
url: string | URL | Request,
|
|
63
|
+
init?: RequestInit,
|
|
64
|
+
): Promise<Response> => {
|
|
65
|
+
const urlStr = typeof url === 'string' ? url : url.toString()
|
|
66
|
+
const method = init?.method ?? 'GET'
|
|
67
|
+
const key = `${method} ${urlStr}`
|
|
68
|
+
|
|
69
|
+
const match =
|
|
70
|
+
responses[key] ??
|
|
71
|
+
Object.entries(responses).find(([k]) => key.startsWith(k))?.[1]
|
|
72
|
+
|
|
73
|
+
if (!match) {
|
|
74
|
+
return new Response(JSON.stringify({ message: 'Not found' }), {
|
|
75
|
+
status: 404,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return new Response(
|
|
80
|
+
match.body !== undefined ? JSON.stringify(match.body) : null,
|
|
81
|
+
{
|
|
82
|
+
status: match.status ?? 200,
|
|
83
|
+
headers: { 'Content-Type': 'application/json' },
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ─── Cleanup ──────────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
afterEach(() => {
|
|
92
|
+
resetAllStores()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
// ─── Schema introspection ──────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
describe('extractFields', () => {
|
|
98
|
+
it('extracts field names and types from a Zod schema', () => {
|
|
99
|
+
const fields = extractFields(userSchema)
|
|
100
|
+
|
|
101
|
+
expect(fields).toHaveLength(5)
|
|
102
|
+
expect(fields[0]).toEqual({
|
|
103
|
+
name: 'name',
|
|
104
|
+
type: 'string',
|
|
105
|
+
optional: false,
|
|
106
|
+
label: 'Name',
|
|
107
|
+
})
|
|
108
|
+
expect(fields[1]).toEqual({
|
|
109
|
+
name: 'email',
|
|
110
|
+
type: 'string',
|
|
111
|
+
optional: false,
|
|
112
|
+
label: 'Email',
|
|
113
|
+
})
|
|
114
|
+
expect(fields[2]).toMatchObject({
|
|
115
|
+
name: 'role',
|
|
116
|
+
type: 'enum',
|
|
117
|
+
optional: false,
|
|
118
|
+
label: 'Role',
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('detects optional fields', () => {
|
|
123
|
+
const fields = extractFields(userSchema)
|
|
124
|
+
const ageField = fields.find((f) => f.name === 'age')
|
|
125
|
+
expect(ageField?.optional).toBe(true)
|
|
126
|
+
expect(ageField?.type).toBe('number')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('detects boolean fields', () => {
|
|
130
|
+
const fields = extractFields(userSchema)
|
|
131
|
+
const activeField = fields.find((f) => f.name === 'active')
|
|
132
|
+
expect(activeField?.type).toBe('boolean')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('converts field names to labels', () => {
|
|
136
|
+
const schema = z.object({
|
|
137
|
+
firstName: z.string(),
|
|
138
|
+
last_name: z.string(),
|
|
139
|
+
email_address: z.string(),
|
|
140
|
+
})
|
|
141
|
+
const fields = extractFields(schema)
|
|
142
|
+
expect(fields[0]!.label).toBe('First Name')
|
|
143
|
+
expect(fields[1]!.label).toBe('Last Name')
|
|
144
|
+
expect(fields[2]!.label).toBe('Email Address')
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns empty array for non-object input', () => {
|
|
148
|
+
expect(extractFields(null)).toEqual([])
|
|
149
|
+
expect(extractFields(undefined)).toEqual([])
|
|
150
|
+
expect(extractFields('string')).toEqual([])
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('handles Zod v3-style schema with _def.typeName', () => {
|
|
154
|
+
// Mock v3 schema shape
|
|
155
|
+
const mockSchema = {
|
|
156
|
+
_def: {
|
|
157
|
+
shape: () => ({
|
|
158
|
+
title: {
|
|
159
|
+
_def: { typeName: 'ZodString' },
|
|
160
|
+
},
|
|
161
|
+
count: {
|
|
162
|
+
_def: { typeName: 'ZodNumber' },
|
|
163
|
+
},
|
|
164
|
+
done: {
|
|
165
|
+
_def: { typeName: 'ZodBoolean' },
|
|
166
|
+
},
|
|
167
|
+
}),
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
const fields = extractFields(mockSchema)
|
|
171
|
+
expect(fields).toHaveLength(3)
|
|
172
|
+
expect(fields[0]).toMatchObject({ name: 'title', type: 'string' })
|
|
173
|
+
expect(fields[1]).toMatchObject({ name: 'count', type: 'number' })
|
|
174
|
+
expect(fields[2]).toMatchObject({ name: 'done', type: 'boolean' })
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('handles Zod v3-style optional with _def.typeName ZodOptional', () => {
|
|
178
|
+
const mockSchema = {
|
|
179
|
+
_def: {
|
|
180
|
+
shape: () => ({
|
|
181
|
+
name: {
|
|
182
|
+
_def: {
|
|
183
|
+
typeName: 'ZodOptional',
|
|
184
|
+
innerType: { _def: { typeName: 'ZodString' } },
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
const fields = extractFields(mockSchema)
|
|
191
|
+
expect(fields[0]).toMatchObject({
|
|
192
|
+
name: 'name',
|
|
193
|
+
type: 'string',
|
|
194
|
+
optional: true,
|
|
195
|
+
})
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('handles Zod v3-style enum with _def.values', () => {
|
|
199
|
+
const mockSchema = {
|
|
200
|
+
_def: {
|
|
201
|
+
shape: () => ({
|
|
202
|
+
status: {
|
|
203
|
+
_def: {
|
|
204
|
+
typeName: 'ZodEnum',
|
|
205
|
+
values: ['active', 'inactive'],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
}),
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
const fields = extractFields(mockSchema)
|
|
212
|
+
expect(fields[0]).toMatchObject({
|
|
213
|
+
name: 'status',
|
|
214
|
+
type: 'enum',
|
|
215
|
+
enumValues: ['active', 'inactive'],
|
|
216
|
+
})
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('returns unknown for unrecognized field type', () => {
|
|
220
|
+
const mockSchema = {
|
|
221
|
+
shape: {
|
|
222
|
+
weird: { _def: { typeName: 'ZodSomethingNew' } },
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
const fields = extractFields(mockSchema)
|
|
226
|
+
expect(fields[0]).toMatchObject({ name: 'weird', type: 'string' })
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('handles schema with static _def.shape object (not function)', () => {
|
|
230
|
+
const mockSchema = {
|
|
231
|
+
_def: {
|
|
232
|
+
shape: {
|
|
233
|
+
name: { _def: { typeName: 'ZodString' } },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
const fields = extractFields(mockSchema)
|
|
238
|
+
expect(fields).toHaveLength(1)
|
|
239
|
+
expect(fields[0]).toMatchObject({ name: 'name', type: 'string' })
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it('handles schema with _zod.def.shape (v4 path)', () => {
|
|
243
|
+
const mockSchema = {
|
|
244
|
+
_zod: {
|
|
245
|
+
def: {
|
|
246
|
+
shape: {
|
|
247
|
+
email: {
|
|
248
|
+
_zod: { def: { type: 'string' } },
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
}
|
|
254
|
+
const fields = extractFields(mockSchema)
|
|
255
|
+
expect(fields).toHaveLength(1)
|
|
256
|
+
expect(fields[0]).toMatchObject({ name: 'email', type: 'string' })
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it('handles getTypeName returning undefined', () => {
|
|
260
|
+
const mockSchema = {
|
|
261
|
+
shape: {
|
|
262
|
+
field: {},
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
const fields = extractFields(mockSchema)
|
|
266
|
+
expect(fields[0]).toMatchObject({ name: 'field', type: 'unknown' })
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe('defaultInitialValues', () => {
|
|
271
|
+
it('generates defaults from field types', () => {
|
|
272
|
+
const fields = extractFields(userSchema)
|
|
273
|
+
const values = defaultInitialValues(fields)
|
|
274
|
+
|
|
275
|
+
expect(values.name).toBe('')
|
|
276
|
+
expect(values.email).toBe('')
|
|
277
|
+
expect(values.age).toBe(0)
|
|
278
|
+
expect(values.active).toBe(false)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
// ─── defineFeature ─────────────────────────────────────────────────────────────
|
|
283
|
+
|
|
284
|
+
describe('defineFeature', () => {
|
|
285
|
+
it('returns a feature with name, api, schema, fields, and queryKey', () => {
|
|
286
|
+
const users = defineFeature<UserValues>({
|
|
287
|
+
name: 'users',
|
|
288
|
+
schema: userSchema,
|
|
289
|
+
api: '/api/users',
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
expect(users.name).toBe('users')
|
|
293
|
+
expect(users.api).toBe('/api/users')
|
|
294
|
+
expect(users.schema).toBe(userSchema)
|
|
295
|
+
expect(users.fields.length).toBeGreaterThan(0)
|
|
296
|
+
expect(users.fields[0]!.name).toBe('name')
|
|
297
|
+
expect(users.queryKey()).toEqual(['users'])
|
|
298
|
+
expect(users.queryKey('123')).toEqual(['users', '123'])
|
|
299
|
+
expect(users.queryKey(42)).toEqual(['users', 42])
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('has all hooks', () => {
|
|
303
|
+
const users = defineFeature<UserValues>({
|
|
304
|
+
name: 'users',
|
|
305
|
+
schema: userSchema,
|
|
306
|
+
api: '/api/users',
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
expect(typeof users.useList).toBe('function')
|
|
310
|
+
expect(typeof users.useById).toBe('function')
|
|
311
|
+
expect(typeof users.useSearch).toBe('function')
|
|
312
|
+
expect(typeof users.useCreate).toBe('function')
|
|
313
|
+
expect(typeof users.useUpdate).toBe('function')
|
|
314
|
+
expect(typeof users.useDelete).toBe('function')
|
|
315
|
+
expect(typeof users.useForm).toBe('function')
|
|
316
|
+
expect(typeof users.useTable).toBe('function')
|
|
317
|
+
expect(typeof users.useStore).toBe('function')
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
it('auto-generates initial values from schema', () => {
|
|
321
|
+
const users = defineFeature<UserValues>({
|
|
322
|
+
name: 'users-auto-init',
|
|
323
|
+
schema: userSchema,
|
|
324
|
+
api: '/api/users',
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
const client = new QueryClient()
|
|
328
|
+
const { result: form, unmount } = mountWith(client, () => users.useForm())
|
|
329
|
+
expect(form.values().name).toBe('')
|
|
330
|
+
expect(form.values().active).toBe(false)
|
|
331
|
+
unmount()
|
|
332
|
+
})
|
|
333
|
+
})
|
|
334
|
+
|
|
335
|
+
// ─── useList ────────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
describe('useList', () => {
|
|
338
|
+
it('fetches list from API', async () => {
|
|
339
|
+
const mockUsers = [
|
|
340
|
+
{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true },
|
|
341
|
+
]
|
|
342
|
+
|
|
343
|
+
const users = defineFeature<UserValues>({
|
|
344
|
+
name: 'users-list',
|
|
345
|
+
schema: userSchema,
|
|
346
|
+
api: '/api/users',
|
|
347
|
+
fetcher: createMockFetch({
|
|
348
|
+
'GET /api/users': { body: mockUsers },
|
|
349
|
+
}) as typeof fetch,
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
const client = new QueryClient({
|
|
353
|
+
defaultOptions: { queries: { retry: false } },
|
|
354
|
+
})
|
|
355
|
+
const { result: query, unmount } = mountWith(client, () => users.useList())
|
|
356
|
+
|
|
357
|
+
expect(query.isPending()).toBe(true)
|
|
358
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
359
|
+
expect(query.data()).toEqual(mockUsers)
|
|
360
|
+
unmount()
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('passes query params to URL', async () => {
|
|
364
|
+
let capturedUrl = ''
|
|
365
|
+
const users = defineFeature<UserValues>({
|
|
366
|
+
name: 'users-params',
|
|
367
|
+
schema: userSchema,
|
|
368
|
+
api: '/api/users',
|
|
369
|
+
fetcher: (async (url: string) => {
|
|
370
|
+
capturedUrl = url
|
|
371
|
+
return new Response('[]', { status: 200 })
|
|
372
|
+
}) as typeof fetch,
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
const client = new QueryClient({
|
|
376
|
+
defaultOptions: { queries: { retry: false } },
|
|
377
|
+
})
|
|
378
|
+
const { unmount } = mountWith(client, () =>
|
|
379
|
+
users.useList({ params: { page: 2 } }),
|
|
380
|
+
)
|
|
381
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
382
|
+
expect(capturedUrl).toContain('page=2')
|
|
383
|
+
unmount()
|
|
384
|
+
})
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
// ─── useList pagination ─────────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
describe('useList pagination', () => {
|
|
390
|
+
it('appends page and pageSize when page is provided as number', async () => {
|
|
391
|
+
let capturedUrl = ''
|
|
392
|
+
const users = defineFeature<UserValues>({
|
|
393
|
+
name: 'users-page-num',
|
|
394
|
+
schema: userSchema,
|
|
395
|
+
api: '/api/users',
|
|
396
|
+
fetcher: (async (url: string) => {
|
|
397
|
+
capturedUrl = url
|
|
398
|
+
return new Response('[]', { status: 200 })
|
|
399
|
+
}) as typeof fetch,
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
const client = new QueryClient({
|
|
403
|
+
defaultOptions: { queries: { retry: false } },
|
|
404
|
+
})
|
|
405
|
+
const { unmount } = mountWith(client, () =>
|
|
406
|
+
users.useList({ page: 1, pageSize: 10 }),
|
|
407
|
+
)
|
|
408
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
409
|
+
expect(capturedUrl).toContain('page=1')
|
|
410
|
+
expect(capturedUrl).toContain('pageSize=10')
|
|
411
|
+
unmount()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('defaults pageSize to 20 when page is provided', async () => {
|
|
415
|
+
let capturedUrl = ''
|
|
416
|
+
const users = defineFeature<UserValues>({
|
|
417
|
+
name: 'users-page-default-size',
|
|
418
|
+
schema: userSchema,
|
|
419
|
+
api: '/api/users',
|
|
420
|
+
fetcher: (async (url: string) => {
|
|
421
|
+
capturedUrl = url
|
|
422
|
+
return new Response('[]', { status: 200 })
|
|
423
|
+
}) as typeof fetch,
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
const client = new QueryClient({
|
|
427
|
+
defaultOptions: { queries: { retry: false } },
|
|
428
|
+
})
|
|
429
|
+
const { unmount } = mountWith(client, () => users.useList({ page: 3 }))
|
|
430
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
431
|
+
expect(capturedUrl).toContain('page=3')
|
|
432
|
+
expect(capturedUrl).toContain('pageSize=20')
|
|
433
|
+
unmount()
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('accepts reactive page signal', async () => {
|
|
437
|
+
const capturedUrls: string[] = []
|
|
438
|
+
const users = defineFeature<UserValues>({
|
|
439
|
+
name: 'users-page-signal',
|
|
440
|
+
schema: userSchema,
|
|
441
|
+
api: '/api/users',
|
|
442
|
+
fetcher: (async (url: string) => {
|
|
443
|
+
capturedUrls.push(url)
|
|
444
|
+
return new Response('[]', { status: 200 })
|
|
445
|
+
}) as typeof fetch,
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
const page = signal(1)
|
|
449
|
+
const client = new QueryClient({
|
|
450
|
+
defaultOptions: { queries: { retry: false } },
|
|
451
|
+
})
|
|
452
|
+
const { unmount } = mountWith(client, () =>
|
|
453
|
+
users.useList({ page, pageSize: 5 }),
|
|
454
|
+
)
|
|
455
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
456
|
+
expect(capturedUrls.some((u) => u.includes('page=1'))).toBe(true)
|
|
457
|
+
unmount()
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('includes page in query key for independent caching', () => {
|
|
461
|
+
const users = defineFeature<UserValues>({
|
|
462
|
+
name: 'users-page-key',
|
|
463
|
+
schema: userSchema,
|
|
464
|
+
api: '/api/users',
|
|
465
|
+
fetcher: (async () => {
|
|
466
|
+
return new Response('[]', { status: 200 })
|
|
467
|
+
}) as typeof fetch,
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
// Different pages should produce different query keys
|
|
471
|
+
const key1 = users.queryKey(1)
|
|
472
|
+
const key2 = users.queryKey(2)
|
|
473
|
+
expect(key1).not.toEqual(key2)
|
|
474
|
+
})
|
|
475
|
+
})
|
|
476
|
+
|
|
477
|
+
// ─── useById ────────────────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
describe('useById', () => {
|
|
480
|
+
it('fetches single item by ID', async () => {
|
|
481
|
+
const mockUser = {
|
|
482
|
+
name: 'Alice',
|
|
483
|
+
email: 'a@t.com',
|
|
484
|
+
role: 'admin',
|
|
485
|
+
active: true,
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const users = defineFeature<UserValues>({
|
|
489
|
+
name: 'users-by-id',
|
|
490
|
+
schema: userSchema,
|
|
491
|
+
api: '/api/users',
|
|
492
|
+
fetcher: createMockFetch({
|
|
493
|
+
'GET /api/users/1': { body: mockUser },
|
|
494
|
+
}) as typeof fetch,
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
const client = new QueryClient({
|
|
498
|
+
defaultOptions: { queries: { retry: false } },
|
|
499
|
+
})
|
|
500
|
+
const { result: query, unmount } = mountWith(client, () => users.useById(1))
|
|
501
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
502
|
+
expect(query.data()).toEqual(mockUser)
|
|
503
|
+
unmount()
|
|
504
|
+
})
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
// ─── useCreate ──────────────────────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
describe('useCreate', () => {
|
|
510
|
+
it('posts to API', async () => {
|
|
511
|
+
const users = defineFeature<UserValues>({
|
|
512
|
+
name: 'users-create',
|
|
513
|
+
schema: userSchema,
|
|
514
|
+
api: '/api/users',
|
|
515
|
+
fetcher: createMockFetch({
|
|
516
|
+
'POST /api/users': { body: { name: 'Created' } },
|
|
517
|
+
}) as typeof fetch,
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const client = new QueryClient()
|
|
521
|
+
const { result: mutation, unmount } = mountWith(client, () =>
|
|
522
|
+
users.useCreate(),
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
mutation.mutate({ name: 'New' })
|
|
526
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
527
|
+
expect(mutation.isSuccess()).toBe(true)
|
|
528
|
+
unmount()
|
|
529
|
+
})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
// ─── useUpdate ──────────────────────────────────────────────────────────────────
|
|
533
|
+
|
|
534
|
+
describe('useUpdate', () => {
|
|
535
|
+
it('sends PUT with id and data', async () => {
|
|
536
|
+
let capturedUrl = ''
|
|
537
|
+
let capturedMethod = ''
|
|
538
|
+
|
|
539
|
+
const users = defineFeature<UserValues>({
|
|
540
|
+
name: 'users-update',
|
|
541
|
+
schema: userSchema,
|
|
542
|
+
api: '/api/users',
|
|
543
|
+
fetcher: (async (url: string, init?: RequestInit) => {
|
|
544
|
+
capturedUrl = url
|
|
545
|
+
capturedMethod = init?.method ?? 'GET'
|
|
546
|
+
return new Response('{}', { status: 200 })
|
|
547
|
+
}) as typeof fetch,
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
const client = new QueryClient()
|
|
551
|
+
const { result: mutation, unmount } = mountWith(client, () =>
|
|
552
|
+
users.useUpdate(),
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
mutation.mutate({ id: 42, data: { name: 'Updated' } })
|
|
556
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
557
|
+
|
|
558
|
+
expect(capturedUrl).toBe('/api/users/42')
|
|
559
|
+
expect(capturedMethod).toBe('PUT')
|
|
560
|
+
expect(mutation.isSuccess()).toBe(true)
|
|
561
|
+
unmount()
|
|
562
|
+
})
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
// ─── Optimistic updates ─────────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
describe('optimistic updates', () => {
|
|
568
|
+
it('updates cache optimistically before server responds', async () => {
|
|
569
|
+
let resolveUpdate: ((value: Response) => void) | undefined
|
|
570
|
+
const mockUser = {
|
|
571
|
+
id: 1,
|
|
572
|
+
name: 'Alice',
|
|
573
|
+
email: 'a@t.com',
|
|
574
|
+
role: 'admin',
|
|
575
|
+
active: true,
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const users = defineFeature<UserValues & { id: number }>({
|
|
579
|
+
name: 'users-optimistic',
|
|
580
|
+
schema: z.object({
|
|
581
|
+
id: z.number(),
|
|
582
|
+
name: z.string().min(2),
|
|
583
|
+
email: z.string().email(),
|
|
584
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
585
|
+
active: z.boolean(),
|
|
586
|
+
}),
|
|
587
|
+
api: '/api/users',
|
|
588
|
+
fetcher: (async (_url: string, init?: RequestInit) => {
|
|
589
|
+
if (init?.method === 'PUT') {
|
|
590
|
+
return new Promise<Response>((resolve) => {
|
|
591
|
+
resolveUpdate = resolve
|
|
592
|
+
})
|
|
593
|
+
}
|
|
594
|
+
return new Response(JSON.stringify(mockUser), {
|
|
595
|
+
status: 200,
|
|
596
|
+
headers: { 'Content-Type': 'application/json' },
|
|
597
|
+
})
|
|
598
|
+
}) as typeof fetch,
|
|
599
|
+
})
|
|
600
|
+
|
|
601
|
+
const client = new QueryClient({
|
|
602
|
+
defaultOptions: { queries: { retry: false } },
|
|
603
|
+
})
|
|
604
|
+
|
|
605
|
+
// Pre-populate cache
|
|
606
|
+
client.setQueryData(['users-optimistic', 1], mockUser)
|
|
607
|
+
|
|
608
|
+
const { result: mutation, unmount } = mountWith(client, () =>
|
|
609
|
+
users.useUpdate(),
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
// Start mutation — should optimistically update cache
|
|
613
|
+
mutation.mutate({ id: 1, data: { name: 'Bob' } })
|
|
614
|
+
await new Promise((r) => setTimeout(r, 20))
|
|
615
|
+
|
|
616
|
+
// Cache should be optimistically updated
|
|
617
|
+
const cached = client.getQueryData(['users-optimistic', 1]) as Record<
|
|
618
|
+
string,
|
|
619
|
+
unknown
|
|
620
|
+
>
|
|
621
|
+
expect(cached.name).toBe('Bob')
|
|
622
|
+
|
|
623
|
+
// Resolve server response
|
|
624
|
+
resolveUpdate!(
|
|
625
|
+
new Response(JSON.stringify({ ...mockUser, name: 'Bob' }), {
|
|
626
|
+
status: 200,
|
|
627
|
+
headers: { 'Content-Type': 'application/json' },
|
|
628
|
+
}),
|
|
629
|
+
)
|
|
630
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
631
|
+
|
|
632
|
+
unmount()
|
|
633
|
+
})
|
|
634
|
+
|
|
635
|
+
it('rolls back cache on server error', async () => {
|
|
636
|
+
const mockUser = {
|
|
637
|
+
id: 2,
|
|
638
|
+
name: 'Alice',
|
|
639
|
+
email: 'a@t.com',
|
|
640
|
+
role: 'admin',
|
|
641
|
+
active: true,
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const users = defineFeature<UserValues & { id: number }>({
|
|
645
|
+
name: 'users-rollback',
|
|
646
|
+
schema: z.object({
|
|
647
|
+
id: z.number(),
|
|
648
|
+
name: z.string().min(2),
|
|
649
|
+
email: z.string().email(),
|
|
650
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
651
|
+
active: z.boolean(),
|
|
652
|
+
}),
|
|
653
|
+
api: '/api/users',
|
|
654
|
+
fetcher: (async (_url: string, init?: RequestInit) => {
|
|
655
|
+
if (init?.method === 'PUT') {
|
|
656
|
+
return new Response(JSON.stringify({ message: 'Server error' }), {
|
|
657
|
+
status: 500,
|
|
658
|
+
headers: { 'Content-Type': 'application/json' },
|
|
659
|
+
})
|
|
660
|
+
}
|
|
661
|
+
return new Response(JSON.stringify(mockUser), {
|
|
662
|
+
status: 200,
|
|
663
|
+
headers: { 'Content-Type': 'application/json' },
|
|
664
|
+
})
|
|
665
|
+
}) as typeof fetch,
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
const client = new QueryClient({
|
|
669
|
+
defaultOptions: { mutations: { retry: false } },
|
|
670
|
+
})
|
|
671
|
+
|
|
672
|
+
// Pre-populate cache
|
|
673
|
+
client.setQueryData(['users-rollback', 2], mockUser)
|
|
674
|
+
|
|
675
|
+
const { result: mutation, unmount } = mountWith(client, () =>
|
|
676
|
+
users.useUpdate(),
|
|
677
|
+
)
|
|
678
|
+
|
|
679
|
+
mutation.mutate({ id: 2, data: { name: 'Should rollback' } })
|
|
680
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
681
|
+
|
|
682
|
+
// Should roll back to original
|
|
683
|
+
const cached = client.getQueryData(['users-rollback', 2]) as Record<
|
|
684
|
+
string,
|
|
685
|
+
unknown
|
|
686
|
+
>
|
|
687
|
+
expect(cached.name).toBe('Alice')
|
|
688
|
+
expect(mutation.isError()).toBe(true)
|
|
689
|
+
unmount()
|
|
690
|
+
})
|
|
691
|
+
})
|
|
692
|
+
|
|
693
|
+
// ─── useDelete ──────────────────────────────────────────────────────────────────
|
|
694
|
+
|
|
695
|
+
describe('useDelete', () => {
|
|
696
|
+
it('sends DELETE with id', async () => {
|
|
697
|
+
let capturedUrl = ''
|
|
698
|
+
let capturedMethod = ''
|
|
699
|
+
|
|
700
|
+
const users = defineFeature<UserValues>({
|
|
701
|
+
name: 'users-delete',
|
|
702
|
+
schema: userSchema,
|
|
703
|
+
api: '/api/users',
|
|
704
|
+
fetcher: (async (url: string, init?: RequestInit) => {
|
|
705
|
+
capturedUrl = url
|
|
706
|
+
capturedMethod = init?.method ?? 'GET'
|
|
707
|
+
return new Response(null, { status: 204 })
|
|
708
|
+
}) as typeof fetch,
|
|
709
|
+
})
|
|
710
|
+
|
|
711
|
+
const client = new QueryClient()
|
|
712
|
+
const { result: mutation, unmount } = mountWith(client, () =>
|
|
713
|
+
users.useDelete(),
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
mutation.mutate(7)
|
|
717
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
718
|
+
|
|
719
|
+
expect(capturedUrl).toBe('/api/users/7')
|
|
720
|
+
expect(capturedMethod).toBe('DELETE')
|
|
721
|
+
expect(mutation.isSuccess()).toBe(true)
|
|
722
|
+
unmount()
|
|
723
|
+
})
|
|
724
|
+
})
|
|
725
|
+
|
|
726
|
+
// ─── useForm ────────────────────────────────────────────────────────────────────
|
|
727
|
+
|
|
728
|
+
describe('useForm', () => {
|
|
729
|
+
it('creates form with schema validation', () => {
|
|
730
|
+
const users = defineFeature<UserValues>({
|
|
731
|
+
name: 'users-form',
|
|
732
|
+
schema: userSchema,
|
|
733
|
+
api: '/api/users',
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
const client = new QueryClient()
|
|
737
|
+
const { result: form, unmount } = mountWith(client, () => users.useForm())
|
|
738
|
+
|
|
739
|
+
expect(typeof form.handleSubmit).toBe('function')
|
|
740
|
+
expect(typeof form.register).toBe('function')
|
|
741
|
+
unmount()
|
|
742
|
+
})
|
|
743
|
+
|
|
744
|
+
it('submits as POST in create mode', async () => {
|
|
745
|
+
let capturedMethod = ''
|
|
746
|
+
const users = defineFeature<UserValues>({
|
|
747
|
+
name: 'users-form-post',
|
|
748
|
+
schema: userSchema,
|
|
749
|
+
api: '/api/users',
|
|
750
|
+
fetcher: (async (_url: string, init?: RequestInit) => {
|
|
751
|
+
capturedMethod = init?.method ?? 'GET'
|
|
752
|
+
return new Response('{}', { status: 200 })
|
|
753
|
+
}) as typeof fetch,
|
|
754
|
+
})
|
|
755
|
+
|
|
756
|
+
const client = new QueryClient()
|
|
757
|
+
const { result: form, unmount } = mountWith(client, () => users.useForm())
|
|
758
|
+
|
|
759
|
+
form.setFieldValue('name', 'Al')
|
|
760
|
+
form.setFieldValue('email', 'a@t.com')
|
|
761
|
+
form.setFieldValue('role', 'admin')
|
|
762
|
+
form.setFieldValue('active', true)
|
|
763
|
+
await form.handleSubmit()
|
|
764
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
765
|
+
|
|
766
|
+
expect(capturedMethod).toBe('POST')
|
|
767
|
+
unmount()
|
|
768
|
+
})
|
|
769
|
+
|
|
770
|
+
it('submits as PUT in edit mode', async () => {
|
|
771
|
+
let capturedMethod = ''
|
|
772
|
+
let capturedUrl = ''
|
|
773
|
+
const users = defineFeature<UserValues>({
|
|
774
|
+
name: 'users-form-put',
|
|
775
|
+
schema: userSchema,
|
|
776
|
+
api: '/api/users',
|
|
777
|
+
fetcher: (async (url: string, init?: RequestInit) => {
|
|
778
|
+
capturedUrl = url
|
|
779
|
+
capturedMethod = init?.method ?? 'GET'
|
|
780
|
+
return new Response('{}', { status: 200 })
|
|
781
|
+
}) as typeof fetch,
|
|
782
|
+
})
|
|
783
|
+
|
|
784
|
+
const client = new QueryClient()
|
|
785
|
+
const { result: form, unmount } = mountWith(client, () =>
|
|
786
|
+
users.useForm({
|
|
787
|
+
mode: 'edit',
|
|
788
|
+
id: 42,
|
|
789
|
+
initialValues: {
|
|
790
|
+
name: 'Al',
|
|
791
|
+
email: 'a@t.com',
|
|
792
|
+
role: 'admin',
|
|
793
|
+
active: true,
|
|
794
|
+
},
|
|
795
|
+
}),
|
|
796
|
+
)
|
|
797
|
+
|
|
798
|
+
// Wait for auto-fetch to complete (will 404 with mock, but form still works)
|
|
799
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
800
|
+
|
|
801
|
+
await form.handleSubmit()
|
|
802
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
803
|
+
|
|
804
|
+
expect(capturedMethod).toBe('PUT')
|
|
805
|
+
expect(capturedUrl).toBe('/api/users/42')
|
|
806
|
+
unmount()
|
|
807
|
+
})
|
|
808
|
+
|
|
809
|
+
it('calls onSuccess callback', async () => {
|
|
810
|
+
let successResult: unknown = null
|
|
811
|
+
const users = defineFeature<UserValues>({
|
|
812
|
+
name: 'users-form-cb',
|
|
813
|
+
schema: userSchema,
|
|
814
|
+
api: '/api/users',
|
|
815
|
+
fetcher: createMockFetch({
|
|
816
|
+
'POST /api/users': { body: { id: 1 } },
|
|
817
|
+
}) as typeof fetch,
|
|
818
|
+
})
|
|
819
|
+
|
|
820
|
+
const client = new QueryClient()
|
|
821
|
+
const { result: form, unmount } = mountWith(client, () =>
|
|
822
|
+
users.useForm({
|
|
823
|
+
onSuccess: (r) => {
|
|
824
|
+
successResult = r
|
|
825
|
+
},
|
|
826
|
+
}),
|
|
827
|
+
)
|
|
828
|
+
|
|
829
|
+
form.setFieldValue('name', 'Al')
|
|
830
|
+
form.setFieldValue('email', 'a@t.com')
|
|
831
|
+
form.setFieldValue('role', 'admin')
|
|
832
|
+
form.setFieldValue('active', true)
|
|
833
|
+
await form.handleSubmit()
|
|
834
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
835
|
+
|
|
836
|
+
expect(successResult).toEqual({ id: 1 })
|
|
837
|
+
unmount()
|
|
838
|
+
})
|
|
839
|
+
})
|
|
840
|
+
|
|
841
|
+
// ─── Auto-fetch edit form ────────────────────────────────────────────────────
|
|
842
|
+
|
|
843
|
+
describe('auto-fetch edit form', () => {
|
|
844
|
+
it('populates form fields from API when mode is edit', async () => {
|
|
845
|
+
const mockUser = {
|
|
846
|
+
name: 'Alice',
|
|
847
|
+
email: 'alice@example.com',
|
|
848
|
+
role: 'admin',
|
|
849
|
+
active: true,
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const users = defineFeature<UserValues>({
|
|
853
|
+
name: 'users-form-autofetch',
|
|
854
|
+
schema: userSchema,
|
|
855
|
+
api: '/api/users',
|
|
856
|
+
fetcher: createMockFetch({
|
|
857
|
+
'GET /api/users/42': { body: mockUser },
|
|
858
|
+
'PUT /api/users/42': { body: mockUser },
|
|
859
|
+
}) as typeof fetch,
|
|
860
|
+
})
|
|
861
|
+
|
|
862
|
+
const client = new QueryClient()
|
|
863
|
+
const { result: form, unmount } = mountWith(client, () =>
|
|
864
|
+
users.useForm({ mode: 'edit', id: 42 }),
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
// Should be loading initially
|
|
868
|
+
expect(form.isSubmitting()).toBe(true)
|
|
869
|
+
|
|
870
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
871
|
+
|
|
872
|
+
// Should have populated values
|
|
873
|
+
expect(form.isSubmitting()).toBe(false)
|
|
874
|
+
expect(form.values().name).toBe('Alice')
|
|
875
|
+
expect(form.values().email).toBe('alice@example.com')
|
|
876
|
+
expect(form.values().role).toBe('admin')
|
|
877
|
+
expect(form.values().active).toBe(true)
|
|
878
|
+
unmount()
|
|
879
|
+
})
|
|
880
|
+
|
|
881
|
+
it('clears loading state on fetch error', async () => {
|
|
882
|
+
const users = defineFeature<UserValues>({
|
|
883
|
+
name: 'users-form-autofetch-err',
|
|
884
|
+
schema: userSchema,
|
|
885
|
+
api: '/api/users',
|
|
886
|
+
fetcher: createMockFetch({
|
|
887
|
+
'GET /api/users/999': { status: 404, body: { message: 'Not found' } },
|
|
888
|
+
}) as typeof fetch,
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
const client = new QueryClient()
|
|
892
|
+
const { result: form, unmount } = mountWith(client, () =>
|
|
893
|
+
users.useForm({ mode: 'edit', id: 999 }),
|
|
894
|
+
)
|
|
895
|
+
|
|
896
|
+
expect(form.isSubmitting()).toBe(true)
|
|
897
|
+
await new Promise((r) => setTimeout(r, 100))
|
|
898
|
+
expect(form.isSubmitting()).toBe(false)
|
|
899
|
+
unmount()
|
|
900
|
+
})
|
|
901
|
+
})
|
|
902
|
+
|
|
903
|
+
// ─── useStore ──────────────────────────────────────────────────────────────────
|
|
904
|
+
|
|
905
|
+
describe('useStore', () => {
|
|
906
|
+
it('returns a store with items, selected, and loading signals', () => {
|
|
907
|
+
const users = defineFeature<UserValues>({
|
|
908
|
+
name: 'users-store',
|
|
909
|
+
schema: userSchema,
|
|
910
|
+
api: '/api/users',
|
|
911
|
+
})
|
|
912
|
+
|
|
913
|
+
const client = new QueryClient()
|
|
914
|
+
const { result: storeApi, unmount } = mountWith(client, () =>
|
|
915
|
+
users.useStore(),
|
|
916
|
+
)
|
|
917
|
+
|
|
918
|
+
expect(storeApi.store.items()).toEqual([])
|
|
919
|
+
expect(storeApi.store.selected()).toBe(null)
|
|
920
|
+
expect(storeApi.store.loading()).toBe(false)
|
|
921
|
+
unmount()
|
|
922
|
+
})
|
|
923
|
+
|
|
924
|
+
it('select() finds item by id from items list', () => {
|
|
925
|
+
const users = defineFeature<UserValues & { id: number }>({
|
|
926
|
+
name: 'users-store-select',
|
|
927
|
+
schema: z.object({
|
|
928
|
+
id: z.number(),
|
|
929
|
+
name: z.string().min(2),
|
|
930
|
+
email: z.string().email(),
|
|
931
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
932
|
+
active: z.boolean(),
|
|
933
|
+
}),
|
|
934
|
+
api: '/api/users',
|
|
935
|
+
})
|
|
936
|
+
|
|
937
|
+
const client = new QueryClient()
|
|
938
|
+
const { result: storeApi, unmount } = mountWith(client, () =>
|
|
939
|
+
users.useStore(),
|
|
940
|
+
)
|
|
941
|
+
|
|
942
|
+
const items = [
|
|
943
|
+
{
|
|
944
|
+
id: 1,
|
|
945
|
+
name: 'Alice',
|
|
946
|
+
email: 'a@t.com',
|
|
947
|
+
role: 'admin' as const,
|
|
948
|
+
active: true,
|
|
949
|
+
},
|
|
950
|
+
{
|
|
951
|
+
id: 2,
|
|
952
|
+
name: 'Bob',
|
|
953
|
+
email: 'b@t.com',
|
|
954
|
+
role: 'editor' as const,
|
|
955
|
+
active: false,
|
|
956
|
+
},
|
|
957
|
+
]
|
|
958
|
+
storeApi.store.items.set(items)
|
|
959
|
+
storeApi.store.select(2)
|
|
960
|
+
|
|
961
|
+
expect(storeApi.store.selected()?.name).toBe('Bob')
|
|
962
|
+
unmount()
|
|
963
|
+
})
|
|
964
|
+
|
|
965
|
+
it('select() sets null when id not found', () => {
|
|
966
|
+
const users = defineFeature<UserValues & { id: number }>({
|
|
967
|
+
name: 'users-store-select-miss',
|
|
968
|
+
schema: z.object({
|
|
969
|
+
id: z.number(),
|
|
970
|
+
name: z.string().min(2),
|
|
971
|
+
email: z.string().email(),
|
|
972
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
973
|
+
active: z.boolean(),
|
|
974
|
+
}),
|
|
975
|
+
api: '/api/users',
|
|
976
|
+
})
|
|
977
|
+
|
|
978
|
+
const client = new QueryClient()
|
|
979
|
+
const { result: storeApi, unmount } = mountWith(client, () =>
|
|
980
|
+
users.useStore(),
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
storeApi.store.items.set([
|
|
984
|
+
{
|
|
985
|
+
id: 1,
|
|
986
|
+
name: 'Alice',
|
|
987
|
+
email: 'a@t.com',
|
|
988
|
+
role: 'admin' as const,
|
|
989
|
+
active: true,
|
|
990
|
+
},
|
|
991
|
+
])
|
|
992
|
+
storeApi.store.select(999)
|
|
993
|
+
|
|
994
|
+
expect(storeApi.store.selected()).toBe(null)
|
|
995
|
+
unmount()
|
|
996
|
+
})
|
|
997
|
+
|
|
998
|
+
it('clear() resets selection to null', () => {
|
|
999
|
+
const users = defineFeature<UserValues & { id: number }>({
|
|
1000
|
+
name: 'users-store-clear',
|
|
1001
|
+
schema: z.object({
|
|
1002
|
+
id: z.number(),
|
|
1003
|
+
name: z.string().min(2),
|
|
1004
|
+
email: z.string().email(),
|
|
1005
|
+
role: z.enum(['admin', 'editor', 'viewer']),
|
|
1006
|
+
active: z.boolean(),
|
|
1007
|
+
}),
|
|
1008
|
+
api: '/api/users',
|
|
1009
|
+
})
|
|
1010
|
+
|
|
1011
|
+
const client = new QueryClient()
|
|
1012
|
+
const { result: storeApi, unmount } = mountWith(client, () =>
|
|
1013
|
+
users.useStore(),
|
|
1014
|
+
)
|
|
1015
|
+
|
|
1016
|
+
storeApi.store.items.set([
|
|
1017
|
+
{
|
|
1018
|
+
id: 1,
|
|
1019
|
+
name: 'Alice',
|
|
1020
|
+
email: 'a@t.com',
|
|
1021
|
+
role: 'admin' as const,
|
|
1022
|
+
active: true,
|
|
1023
|
+
},
|
|
1024
|
+
])
|
|
1025
|
+
storeApi.store.select(1)
|
|
1026
|
+
expect(storeApi.store.selected()).not.toBe(null)
|
|
1027
|
+
|
|
1028
|
+
storeApi.store.clear()
|
|
1029
|
+
expect(storeApi.store.selected()).toBe(null)
|
|
1030
|
+
unmount()
|
|
1031
|
+
})
|
|
1032
|
+
|
|
1033
|
+
it('loading signal reflects loading state', () => {
|
|
1034
|
+
const users = defineFeature<UserValues>({
|
|
1035
|
+
name: 'users-store-loading',
|
|
1036
|
+
schema: userSchema,
|
|
1037
|
+
api: '/api/users',
|
|
1038
|
+
})
|
|
1039
|
+
|
|
1040
|
+
const client = new QueryClient()
|
|
1041
|
+
const { result: storeApi, unmount } = mountWith(client, () =>
|
|
1042
|
+
users.useStore(),
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
expect(storeApi.store.loading()).toBe(false)
|
|
1046
|
+
storeApi.store.loading.set(true)
|
|
1047
|
+
expect(storeApi.store.loading()).toBe(true)
|
|
1048
|
+
unmount()
|
|
1049
|
+
})
|
|
1050
|
+
|
|
1051
|
+
it('is singleton — same store returned on multiple calls', () => {
|
|
1052
|
+
const users = defineFeature<UserValues>({
|
|
1053
|
+
name: 'users-store-singleton',
|
|
1054
|
+
schema: userSchema,
|
|
1055
|
+
api: '/api/users',
|
|
1056
|
+
})
|
|
1057
|
+
|
|
1058
|
+
const client = new QueryClient()
|
|
1059
|
+
const { result: store1, unmount: unmount1 } = mountWith(client, () =>
|
|
1060
|
+
users.useStore(),
|
|
1061
|
+
)
|
|
1062
|
+
const { result: store2, unmount: unmount2 } = mountWith(client, () =>
|
|
1063
|
+
users.useStore(),
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
expect(store1.id).toBe(store2.id)
|
|
1067
|
+
store1.store.loading.set(true)
|
|
1068
|
+
expect(store2.store.loading()).toBe(true)
|
|
1069
|
+
unmount1()
|
|
1070
|
+
unmount2()
|
|
1071
|
+
})
|
|
1072
|
+
})
|
|
1073
|
+
|
|
1074
|
+
// ─── useTable ───────────────────────────────────────────────────────────────────
|
|
1075
|
+
|
|
1076
|
+
describe('useTable', () => {
|
|
1077
|
+
it('creates table with schema-inferred columns', () => {
|
|
1078
|
+
const users = defineFeature<UserValues>({
|
|
1079
|
+
name: 'users-table',
|
|
1080
|
+
schema: userSchema,
|
|
1081
|
+
api: '/api/users',
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
const data: UserValues[] = [
|
|
1085
|
+
{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true },
|
|
1086
|
+
]
|
|
1087
|
+
|
|
1088
|
+
const client = new QueryClient()
|
|
1089
|
+
const { result, unmount } = mountWith(client, () => users.useTable(data))
|
|
1090
|
+
|
|
1091
|
+
expect(result.columns.length).toBeGreaterThan(0)
|
|
1092
|
+
expect(result.columns[0]!.name).toBe('name')
|
|
1093
|
+
expect(result.columns[0]!.label).toBe('Name')
|
|
1094
|
+
expect(result.table().getRowModel().rows).toHaveLength(1)
|
|
1095
|
+
unmount()
|
|
1096
|
+
})
|
|
1097
|
+
|
|
1098
|
+
it('filters columns by name', () => {
|
|
1099
|
+
const users = defineFeature<UserValues>({
|
|
1100
|
+
name: 'users-table-cols',
|
|
1101
|
+
schema: userSchema,
|
|
1102
|
+
api: '/api/users',
|
|
1103
|
+
})
|
|
1104
|
+
|
|
1105
|
+
const client = new QueryClient()
|
|
1106
|
+
const { result, unmount } = mountWith(client, () =>
|
|
1107
|
+
users.useTable(
|
|
1108
|
+
[{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true }],
|
|
1109
|
+
{ columns: ['name', 'email'] },
|
|
1110
|
+
),
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
expect(result.columns).toHaveLength(2)
|
|
1114
|
+
expect(result.columns.map((c) => c.name)).toEqual(['name', 'email'])
|
|
1115
|
+
unmount()
|
|
1116
|
+
})
|
|
1117
|
+
|
|
1118
|
+
it('accepts reactive data function', () => {
|
|
1119
|
+
const users = defineFeature<UserValues>({
|
|
1120
|
+
name: 'users-table-fn',
|
|
1121
|
+
schema: userSchema,
|
|
1122
|
+
api: '/api/users',
|
|
1123
|
+
})
|
|
1124
|
+
|
|
1125
|
+
const data = signal<UserValues[]>([
|
|
1126
|
+
{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true },
|
|
1127
|
+
])
|
|
1128
|
+
|
|
1129
|
+
const client = new QueryClient()
|
|
1130
|
+
const { result, unmount } = mountWith(client, () =>
|
|
1131
|
+
users.useTable(() => data()),
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
expect(result.table().getRowModel().rows).toHaveLength(1)
|
|
1135
|
+
unmount()
|
|
1136
|
+
})
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
// ─── useSearch ──────────────────────────────────────────────────────────────────
|
|
1140
|
+
|
|
1141
|
+
describe('useSearch', () => {
|
|
1142
|
+
it('passes search term as query param', async () => {
|
|
1143
|
+
let capturedUrl = ''
|
|
1144
|
+
const users = defineFeature<UserValues>({
|
|
1145
|
+
name: 'users-search',
|
|
1146
|
+
schema: userSchema,
|
|
1147
|
+
api: '/api/users',
|
|
1148
|
+
fetcher: (async (url: string) => {
|
|
1149
|
+
capturedUrl = url
|
|
1150
|
+
return new Response('[]', { status: 200 })
|
|
1151
|
+
}) as typeof fetch,
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
const term = signal('alice')
|
|
1155
|
+
const client = new QueryClient({
|
|
1156
|
+
defaultOptions: { queries: { retry: false } },
|
|
1157
|
+
})
|
|
1158
|
+
const { unmount } = mountWith(client, () => users.useSearch(term))
|
|
1159
|
+
|
|
1160
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1161
|
+
expect(capturedUrl).toContain('q=alice')
|
|
1162
|
+
unmount()
|
|
1163
|
+
})
|
|
1164
|
+
|
|
1165
|
+
it('disables query when search term is empty', () => {
|
|
1166
|
+
const users = defineFeature<UserValues>({
|
|
1167
|
+
name: 'users-search-empty',
|
|
1168
|
+
schema: userSchema,
|
|
1169
|
+
api: '/api/users',
|
|
1170
|
+
fetcher: (() => {
|
|
1171
|
+
throw new Error('Should not fetch')
|
|
1172
|
+
}) as typeof fetch,
|
|
1173
|
+
})
|
|
1174
|
+
|
|
1175
|
+
const term = signal('')
|
|
1176
|
+
const client = new QueryClient()
|
|
1177
|
+
const { result: query, unmount } = mountWith(client, () =>
|
|
1178
|
+
users.useSearch(term),
|
|
1179
|
+
)
|
|
1180
|
+
|
|
1181
|
+
expect(query.isPending()).toBe(true)
|
|
1182
|
+
expect(query.isFetching()).toBe(false)
|
|
1183
|
+
unmount()
|
|
1184
|
+
})
|
|
1185
|
+
})
|
|
1186
|
+
|
|
1187
|
+
// ─── Error handling ────────────────────────────────────────────────────────────
|
|
1188
|
+
|
|
1189
|
+
describe('error handling', () => {
|
|
1190
|
+
it('handles API errors in useList', async () => {
|
|
1191
|
+
const users = defineFeature<UserValues>({
|
|
1192
|
+
name: 'users-err',
|
|
1193
|
+
schema: userSchema,
|
|
1194
|
+
api: '/api/users',
|
|
1195
|
+
fetcher: createMockFetch({
|
|
1196
|
+
'GET /api/users': { status: 500, body: { message: 'Server error' } },
|
|
1197
|
+
}) as typeof fetch,
|
|
1198
|
+
})
|
|
1199
|
+
|
|
1200
|
+
const client = new QueryClient({
|
|
1201
|
+
defaultOptions: { queries: { retry: false } },
|
|
1202
|
+
})
|
|
1203
|
+
const { result: query, unmount } = mountWith(client, () => users.useList())
|
|
1204
|
+
|
|
1205
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1206
|
+
expect(query.isError()).toBe(true)
|
|
1207
|
+
unmount()
|
|
1208
|
+
})
|
|
1209
|
+
|
|
1210
|
+
it('parses structured error body from API', async () => {
|
|
1211
|
+
const users = defineFeature<UserValues>({
|
|
1212
|
+
name: 'users-err-struct',
|
|
1213
|
+
schema: userSchema,
|
|
1214
|
+
api: '/api/users',
|
|
1215
|
+
fetcher: createMockFetch({
|
|
1216
|
+
'POST /api/users': {
|
|
1217
|
+
status: 422,
|
|
1218
|
+
body: { message: 'Validation failed', errors: { email: 'Taken' } },
|
|
1219
|
+
},
|
|
1220
|
+
}) as typeof fetch,
|
|
1221
|
+
})
|
|
1222
|
+
|
|
1223
|
+
const client = new QueryClient()
|
|
1224
|
+
const { result: mutation, unmount } = mountWith(client, () =>
|
|
1225
|
+
users.useCreate(),
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
mutation.mutate({ name: 'Test' })
|
|
1229
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1230
|
+
|
|
1231
|
+
expect(mutation.isError()).toBe(true)
|
|
1232
|
+
const err = mutation.error() as Error & { errors?: unknown }
|
|
1233
|
+
expect(err.message).toBe('Validation failed')
|
|
1234
|
+
expect(err.errors).toEqual({ email: 'Taken' })
|
|
1235
|
+
unmount()
|
|
1236
|
+
})
|
|
1237
|
+
})
|
|
1238
|
+
|
|
1239
|
+
// ─── References ─────────────────────────────────────────────────────────────────
|
|
1240
|
+
|
|
1241
|
+
describe('reference', () => {
|
|
1242
|
+
it('creates a reference schema object', () => {
|
|
1243
|
+
const users = defineFeature<UserValues>({
|
|
1244
|
+
name: 'users-ref',
|
|
1245
|
+
schema: userSchema,
|
|
1246
|
+
api: '/api/users',
|
|
1247
|
+
})
|
|
1248
|
+
|
|
1249
|
+
const ref = reference(users)
|
|
1250
|
+
expect(isReference(ref)).toBe(true)
|
|
1251
|
+
expect(ref._featureName).toBe('users-ref')
|
|
1252
|
+
})
|
|
1253
|
+
|
|
1254
|
+
it('validates string and number IDs', () => {
|
|
1255
|
+
const users = defineFeature<UserValues>({
|
|
1256
|
+
name: 'users-ref-validate',
|
|
1257
|
+
schema: userSchema,
|
|
1258
|
+
api: '/api/users',
|
|
1259
|
+
})
|
|
1260
|
+
|
|
1261
|
+
const ref = reference(users)
|
|
1262
|
+
expect(ref.safeParse(42).success).toBe(true)
|
|
1263
|
+
expect(ref.safeParse('abc-123').success).toBe(true)
|
|
1264
|
+
expect(ref.safeParse(null).success).toBe(false)
|
|
1265
|
+
expect(ref.safeParse(undefined).success).toBe(false)
|
|
1266
|
+
expect(ref.safeParse({}).success).toBe(false)
|
|
1267
|
+
})
|
|
1268
|
+
|
|
1269
|
+
it('safeParseAsync works the same as safeParse', async () => {
|
|
1270
|
+
const ref = reference({ name: 'test' })
|
|
1271
|
+
const result = await ref.safeParseAsync(42)
|
|
1272
|
+
expect(result.success).toBe(true)
|
|
1273
|
+
|
|
1274
|
+
const fail = await ref.safeParseAsync(null)
|
|
1275
|
+
expect(fail.success).toBe(false)
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
it('isReference returns false for non-reference objects', () => {
|
|
1279
|
+
expect(isReference(null)).toBe(false)
|
|
1280
|
+
expect(isReference(undefined)).toBe(false)
|
|
1281
|
+
expect(isReference(42)).toBe(false)
|
|
1282
|
+
expect(isReference('string')).toBe(false)
|
|
1283
|
+
expect(isReference({})).toBe(false)
|
|
1284
|
+
expect(isReference(z.string())).toBe(false)
|
|
1285
|
+
})
|
|
1286
|
+
|
|
1287
|
+
it('reference fields detected in schema introspection', () => {
|
|
1288
|
+
const users = defineFeature<UserValues>({
|
|
1289
|
+
name: 'users-ref-introspect',
|
|
1290
|
+
schema: userSchema,
|
|
1291
|
+
api: '/api/users',
|
|
1292
|
+
})
|
|
1293
|
+
|
|
1294
|
+
const postSchema = z.object({
|
|
1295
|
+
title: z.string(),
|
|
1296
|
+
body: z.string(),
|
|
1297
|
+
authorId: reference(users) as any,
|
|
1298
|
+
})
|
|
1299
|
+
|
|
1300
|
+
const fields = extractFields(postSchema)
|
|
1301
|
+
const authorField = fields.find((f) => f.name === 'authorId')
|
|
1302
|
+
expect(authorField).toBeDefined()
|
|
1303
|
+
expect(authorField!.type).toBe('reference')
|
|
1304
|
+
expect(authorField!.referenceTo).toBe('users-ref-introspect')
|
|
1305
|
+
})
|
|
1306
|
+
|
|
1307
|
+
it('reference fields in defineFeature schema produce reference FieldInfo', () => {
|
|
1308
|
+
const users = defineFeature<UserValues>({
|
|
1309
|
+
name: 'users-ref-in-feat',
|
|
1310
|
+
schema: userSchema,
|
|
1311
|
+
api: '/api/users',
|
|
1312
|
+
})
|
|
1313
|
+
|
|
1314
|
+
type PostValues = { title: string; body: string; authorId: string }
|
|
1315
|
+
const posts = defineFeature<PostValues>({
|
|
1316
|
+
name: 'posts-ref',
|
|
1317
|
+
schema: z.object({
|
|
1318
|
+
title: z.string(),
|
|
1319
|
+
body: z.string(),
|
|
1320
|
+
authorId: reference(users) as any,
|
|
1321
|
+
}),
|
|
1322
|
+
api: '/api/posts',
|
|
1323
|
+
})
|
|
1324
|
+
|
|
1325
|
+
const authorField = posts.fields.find((f) => f.name === 'authorId')
|
|
1326
|
+
expect(authorField).toBeDefined()
|
|
1327
|
+
expect(authorField!.type).toBe('reference')
|
|
1328
|
+
expect(authorField!.referenceTo).toBe('users-ref-in-feat')
|
|
1329
|
+
})
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
// ─── Edge case coverage ────────────────────────────────────────────────────────
|
|
1333
|
+
|
|
1334
|
+
describe('edge cases', () => {
|
|
1335
|
+
it('defineFeature works without a Zod schema (no validation)', () => {
|
|
1336
|
+
const items = defineFeature<{ title: string }>({
|
|
1337
|
+
name: 'items-no-zod',
|
|
1338
|
+
schema: { notAZodSchema: true },
|
|
1339
|
+
api: '/api/items',
|
|
1340
|
+
})
|
|
1341
|
+
|
|
1342
|
+
const client = new QueryClient()
|
|
1343
|
+
const { result: form, unmount } = mountWith(client, () => items.useForm())
|
|
1344
|
+
expect(typeof form.handleSubmit).toBe('function')
|
|
1345
|
+
unmount()
|
|
1346
|
+
})
|
|
1347
|
+
|
|
1348
|
+
it('useForm onError callback fires on submit failure', async () => {
|
|
1349
|
+
let caughtError: unknown = null
|
|
1350
|
+
const users = defineFeature<UserValues>({
|
|
1351
|
+
name: 'users-form-onerror',
|
|
1352
|
+
schema: userSchema,
|
|
1353
|
+
api: '/api/users',
|
|
1354
|
+
fetcher: createMockFetch({
|
|
1355
|
+
'POST /api/users': {
|
|
1356
|
+
status: 500,
|
|
1357
|
+
body: { message: 'Server broke' },
|
|
1358
|
+
},
|
|
1359
|
+
}) as typeof fetch,
|
|
1360
|
+
})
|
|
1361
|
+
|
|
1362
|
+
const client = new QueryClient()
|
|
1363
|
+
const { result: form, unmount } = mountWith(client, () =>
|
|
1364
|
+
users.useForm({
|
|
1365
|
+
onError: (err) => {
|
|
1366
|
+
caughtError = err
|
|
1367
|
+
},
|
|
1368
|
+
}),
|
|
1369
|
+
)
|
|
1370
|
+
|
|
1371
|
+
form.setFieldValue('name', 'Al')
|
|
1372
|
+
form.setFieldValue('email', 'a@t.com')
|
|
1373
|
+
form.setFieldValue('role', 'admin')
|
|
1374
|
+
form.setFieldValue('active', true)
|
|
1375
|
+
|
|
1376
|
+
try {
|
|
1377
|
+
await form.handleSubmit()
|
|
1378
|
+
} catch {
|
|
1379
|
+
// expected
|
|
1380
|
+
}
|
|
1381
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1382
|
+
|
|
1383
|
+
expect(caughtError).toBeInstanceOf(Error)
|
|
1384
|
+
unmount()
|
|
1385
|
+
})
|
|
1386
|
+
|
|
1387
|
+
it('useTable sorting via direct state object (non-function updater)', () => {
|
|
1388
|
+
const users = defineFeature<UserValues>({
|
|
1389
|
+
name: 'users-table-sort-direct',
|
|
1390
|
+
schema: userSchema,
|
|
1391
|
+
api: '/api/users',
|
|
1392
|
+
})
|
|
1393
|
+
|
|
1394
|
+
const data: UserValues[] = [
|
|
1395
|
+
{ name: 'Bob', email: 'b@t.com', role: 'editor', active: true },
|
|
1396
|
+
{ name: 'Alice', email: 'a@t.com', role: 'admin', active: true },
|
|
1397
|
+
]
|
|
1398
|
+
|
|
1399
|
+
const client = new QueryClient()
|
|
1400
|
+
const { result, unmount } = mountWith(client, () => users.useTable(data))
|
|
1401
|
+
|
|
1402
|
+
// Trigger sorting via the table's toggle handler (function updater)
|
|
1403
|
+
result.table().getColumn('name')!.toggleSorting(false)
|
|
1404
|
+
expect(result.sorting().length).toBe(1)
|
|
1405
|
+
|
|
1406
|
+
// Also set sorting directly (non-function updater path)
|
|
1407
|
+
result.sorting.set([{ id: 'email', desc: true }])
|
|
1408
|
+
expect(result.sorting()).toEqual([{ id: 'email', desc: true }])
|
|
1409
|
+
|
|
1410
|
+
// Set globalFilter directly
|
|
1411
|
+
result.globalFilter.set('alice')
|
|
1412
|
+
expect(result.globalFilter()).toBe('alice')
|
|
1413
|
+
|
|
1414
|
+
unmount()
|
|
1415
|
+
})
|
|
1416
|
+
})
|