digital-objects 1.0.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 (87) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +25 -0
  3. package/LICENSE +21 -0
  4. package/README.md +476 -0
  5. package/dist/ai-database-adapter.d.ts +49 -0
  6. package/dist/ai-database-adapter.d.ts.map +1 -0
  7. package/dist/ai-database-adapter.js +89 -0
  8. package/dist/ai-database-adapter.js.map +1 -0
  9. package/dist/errors.d.ts +47 -0
  10. package/dist/errors.d.ts.map +1 -0
  11. package/dist/errors.js +72 -0
  12. package/dist/errors.js.map +1 -0
  13. package/dist/http-schemas.d.ts +165 -0
  14. package/dist/http-schemas.d.ts.map +1 -0
  15. package/dist/http-schemas.js +55 -0
  16. package/dist/http-schemas.js.map +1 -0
  17. package/dist/index.d.ts +29 -0
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +32 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/linguistic.d.ts +54 -0
  22. package/dist/linguistic.d.ts.map +1 -0
  23. package/dist/linguistic.js +226 -0
  24. package/dist/linguistic.js.map +1 -0
  25. package/dist/memory-provider.d.ts +46 -0
  26. package/dist/memory-provider.d.ts.map +1 -0
  27. package/dist/memory-provider.js +279 -0
  28. package/dist/memory-provider.js.map +1 -0
  29. package/dist/ns-client.d.ts +88 -0
  30. package/dist/ns-client.d.ts.map +1 -0
  31. package/dist/ns-client.js +253 -0
  32. package/dist/ns-client.js.map +1 -0
  33. package/dist/ns-exports.d.ts +23 -0
  34. package/dist/ns-exports.d.ts.map +1 -0
  35. package/dist/ns-exports.js +21 -0
  36. package/dist/ns-exports.js.map +1 -0
  37. package/dist/ns.d.ts +60 -0
  38. package/dist/ns.d.ts.map +1 -0
  39. package/dist/ns.js +818 -0
  40. package/dist/ns.js.map +1 -0
  41. package/dist/r2-persistence.d.ts +112 -0
  42. package/dist/r2-persistence.d.ts.map +1 -0
  43. package/dist/r2-persistence.js +252 -0
  44. package/dist/r2-persistence.js.map +1 -0
  45. package/dist/schema-validation.d.ts +80 -0
  46. package/dist/schema-validation.d.ts.map +1 -0
  47. package/dist/schema-validation.js +233 -0
  48. package/dist/schema-validation.js.map +1 -0
  49. package/dist/types.d.ts +184 -0
  50. package/dist/types.d.ts.map +1 -0
  51. package/dist/types.js +26 -0
  52. package/dist/types.js.map +1 -0
  53. package/package.json +55 -0
  54. package/src/ai-database-adapter.test.ts +610 -0
  55. package/src/ai-database-adapter.ts +189 -0
  56. package/src/benchmark.test.ts +109 -0
  57. package/src/errors.ts +91 -0
  58. package/src/http-schemas.ts +67 -0
  59. package/src/index.ts +87 -0
  60. package/src/linguistic.test.ts +1107 -0
  61. package/src/linguistic.ts +253 -0
  62. package/src/memory-provider.ts +470 -0
  63. package/src/ns-client.test.ts +1360 -0
  64. package/src/ns-client.ts +342 -0
  65. package/src/ns-exports.ts +23 -0
  66. package/src/ns.test.ts +1381 -0
  67. package/src/ns.ts +1215 -0
  68. package/src/provider.test.ts +675 -0
  69. package/src/r2-persistence.test.ts +263 -0
  70. package/src/r2-persistence.ts +367 -0
  71. package/src/schema-validation.test.ts +167 -0
  72. package/src/schema-validation.ts +330 -0
  73. package/src/types.ts +252 -0
  74. package/test/action-status.test.ts +42 -0
  75. package/test/batch-limits.test.ts +165 -0
  76. package/test/docs.test.ts +48 -0
  77. package/test/errors.test.ts +148 -0
  78. package/test/http-validation.test.ts +401 -0
  79. package/test/ns-client-errors.test.ts +208 -0
  80. package/test/ns-namespace.test.ts +307 -0
  81. package/test/performance.test.ts +168 -0
  82. package/test/schema-validation-error.test.ts +213 -0
  83. package/test/schema-validation.test.ts +440 -0
  84. package/test/search-escaping.test.ts +359 -0
  85. package/test/security.test.ts +322 -0
  86. package/tsconfig.json +10 -0
  87. package/wrangler.jsonc +16 -0
@@ -0,0 +1,1360 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { NSClient, createNSClient } from './ns-client.js'
3
+
4
+ // Mock fetch globally
5
+ const mockFetch = vi.fn()
6
+
7
+ describe('NSClient', () => {
8
+ let client: NSClient
9
+
10
+ beforeEach(() => {
11
+ mockFetch.mockReset()
12
+ client = new NSClient({
13
+ baseUrl: 'https://example.com/ns',
14
+ namespace: 'test-namespace',
15
+ fetch: mockFetch as typeof fetch,
16
+ })
17
+ })
18
+
19
+ // ==================== Constructor & Configuration ====================
20
+
21
+ describe('Constructor', () => {
22
+ it('should create client with required options', () => {
23
+ const newClient = new NSClient({
24
+ baseUrl: 'https://example.com',
25
+ fetch: mockFetch as typeof fetch,
26
+ })
27
+ expect(newClient).toBeInstanceOf(NSClient)
28
+ })
29
+
30
+ it('should use default namespace when not provided', async () => {
31
+ const defaultNsClient = new NSClient({
32
+ baseUrl: 'https://example.com',
33
+ fetch: mockFetch as typeof fetch,
34
+ })
35
+
36
+ mockFetch.mockResolvedValueOnce({
37
+ ok: true,
38
+ json: async () => [],
39
+ })
40
+
41
+ await defaultNsClient.listNouns()
42
+
43
+ expect(mockFetch).toHaveBeenCalledWith(
44
+ expect.stringContaining('ns=default'),
45
+ expect.any(Object)
46
+ )
47
+ })
48
+
49
+ it('should strip trailing slash from base URL', async () => {
50
+ const clientWithSlash = new NSClient({
51
+ baseUrl: 'https://example.com/',
52
+ namespace: 'test',
53
+ fetch: mockFetch as typeof fetch,
54
+ })
55
+
56
+ mockFetch.mockResolvedValueOnce({
57
+ ok: true,
58
+ json: async () => [],
59
+ })
60
+
61
+ await clientWithSlash.listNouns()
62
+
63
+ // Should not have double slashes (except in protocol)
64
+ const calledUrl = mockFetch.mock.calls[0][0] as string
65
+ // Remove protocol for double-slash check
66
+ const pathPart = calledUrl.replace('https://', '')
67
+ expect(pathPart).not.toContain('//')
68
+ expect(calledUrl.startsWith('https://example.com/nouns')).toBe(true)
69
+ })
70
+
71
+ it('should use custom fetch function when provided', async () => {
72
+ const customFetch = vi.fn().mockResolvedValue({
73
+ ok: true,
74
+ json: async () => [],
75
+ })
76
+
77
+ const customClient = new NSClient({
78
+ baseUrl: 'https://example.com',
79
+ fetch: customFetch as typeof fetch,
80
+ })
81
+
82
+ await customClient.listNouns()
83
+
84
+ expect(customFetch).toHaveBeenCalled()
85
+ })
86
+ })
87
+
88
+ // ==================== HTTP Methods ====================
89
+
90
+ describe('HTTP methods', () => {
91
+ it('should make GET requests', async () => {
92
+ mockFetch.mockResolvedValueOnce({
93
+ ok: true,
94
+ json: async () => ({
95
+ name: 'Test',
96
+ singular: 'test',
97
+ plural: 'tests',
98
+ slug: 'test',
99
+ createdAt: new Date().toISOString(),
100
+ }),
101
+ })
102
+
103
+ const result = await client.getNoun('Test')
104
+
105
+ expect(mockFetch).toHaveBeenCalledWith(
106
+ expect.stringContaining('/nouns/Test'),
107
+ expect.objectContaining({ headers: { 'Content-Type': 'application/json' } })
108
+ )
109
+ expect(result?.name).toBe('Test')
110
+ })
111
+
112
+ it('should make POST requests with JSON body', async () => {
113
+ mockFetch.mockResolvedValueOnce({
114
+ ok: true,
115
+ json: async () => ({
116
+ name: 'Test',
117
+ singular: 'test',
118
+ plural: 'tests',
119
+ slug: 'test',
120
+ createdAt: new Date().toISOString(),
121
+ }),
122
+ })
123
+
124
+ await client.defineNoun({ name: 'Test' })
125
+
126
+ expect(mockFetch).toHaveBeenCalledWith(
127
+ expect.stringContaining('/nouns'),
128
+ expect.objectContaining({
129
+ method: 'POST',
130
+ body: JSON.stringify({ name: 'Test' }),
131
+ headers: { 'Content-Type': 'application/json' },
132
+ })
133
+ )
134
+ })
135
+
136
+ it('should make PATCH requests', async () => {
137
+ mockFetch.mockResolvedValueOnce({
138
+ ok: true,
139
+ json: async () => ({
140
+ id: 'thing-1',
141
+ noun: 'Post',
142
+ data: { title: 'Updated' },
143
+ createdAt: new Date().toISOString(),
144
+ updatedAt: new Date().toISOString(),
145
+ }),
146
+ })
147
+
148
+ await client.update('thing-1', { title: 'Updated' })
149
+
150
+ expect(mockFetch).toHaveBeenCalledWith(
151
+ expect.stringContaining('/things/thing-1'),
152
+ expect.objectContaining({
153
+ method: 'PATCH',
154
+ body: JSON.stringify({ title: 'Updated' }),
155
+ })
156
+ )
157
+ })
158
+
159
+ it('should make DELETE requests for things', async () => {
160
+ mockFetch.mockResolvedValueOnce({
161
+ ok: true,
162
+ json: async () => ({ deleted: true }),
163
+ })
164
+
165
+ const result = await client.delete('thing-1')
166
+
167
+ expect(mockFetch).toHaveBeenCalledWith(
168
+ expect.stringContaining('/things/thing-1'),
169
+ expect.objectContaining({ method: 'DELETE' })
170
+ )
171
+ expect(result).toBe(true)
172
+ })
173
+
174
+ it('should make DELETE requests for actions', async () => {
175
+ mockFetch.mockResolvedValueOnce({
176
+ ok: true,
177
+ json: async () => ({ deleted: true }),
178
+ })
179
+
180
+ const result = await client.deleteAction('action-1')
181
+
182
+ expect(mockFetch).toHaveBeenCalledWith(
183
+ expect.stringContaining('/actions/action-1'),
184
+ expect.objectContaining({ method: 'DELETE' })
185
+ )
186
+ expect(result).toBe(true)
187
+ })
188
+
189
+ it('should include namespace in all requests', async () => {
190
+ mockFetch.mockResolvedValueOnce({
191
+ ok: true,
192
+ json: async () => [],
193
+ })
194
+
195
+ await client.listNouns()
196
+
197
+ expect(mockFetch).toHaveBeenCalledWith(
198
+ expect.stringContaining('ns=test-namespace'),
199
+ expect.any(Object)
200
+ )
201
+ })
202
+ })
203
+
204
+ // ==================== Error Handling ====================
205
+
206
+ describe('Error handling', () => {
207
+ it('should return null for 404 responses on getNoun', async () => {
208
+ mockFetch.mockResolvedValueOnce({
209
+ ok: false,
210
+ status: 404,
211
+ text: async () => 'Not found',
212
+ })
213
+
214
+ const result = await client.getNoun('NonExistent')
215
+ expect(result).toBeNull()
216
+ })
217
+
218
+ it('should return null for 404 responses on getVerb', async () => {
219
+ mockFetch.mockResolvedValueOnce({
220
+ ok: false,
221
+ status: 404,
222
+ text: async () => 'Not found',
223
+ })
224
+
225
+ const result = await client.getVerb('NonExistent')
226
+ expect(result).toBeNull()
227
+ })
228
+
229
+ it('should return null for 404 responses on get', async () => {
230
+ mockFetch.mockResolvedValueOnce({
231
+ ok: false,
232
+ status: 404,
233
+ text: async () => 'Not found',
234
+ })
235
+
236
+ const result = await client.get('non-existent-id')
237
+ expect(result).toBeNull()
238
+ })
239
+
240
+ it('should return null for 404 responses on getAction', async () => {
241
+ mockFetch.mockResolvedValueOnce({
242
+ ok: false,
243
+ status: 404,
244
+ text: async () => 'Not found',
245
+ })
246
+
247
+ const result = await client.getAction('non-existent-id')
248
+ expect(result).toBeNull()
249
+ })
250
+
251
+ it('should throw on 500 errors', async () => {
252
+ mockFetch.mockResolvedValueOnce({
253
+ ok: false,
254
+ status: 500,
255
+ text: async () => 'Internal server error',
256
+ })
257
+
258
+ await expect(client.defineNoun({ name: 'Test' })).rejects.toThrow('NS request failed: 500')
259
+ })
260
+
261
+ it('should throw on 400 errors', async () => {
262
+ mockFetch.mockResolvedValueOnce({
263
+ ok: false,
264
+ status: 400,
265
+ text: async () => 'Bad request: invalid data',
266
+ })
267
+
268
+ await expect(client.create('Post', { invalid: true })).rejects.toThrow(
269
+ 'NS request failed: 400'
270
+ )
271
+ })
272
+
273
+ it('should throw on 401 unauthorized errors', async () => {
274
+ mockFetch.mockResolvedValueOnce({
275
+ ok: false,
276
+ status: 401,
277
+ text: async () => 'Unauthorized',
278
+ })
279
+
280
+ await expect(client.listNouns()).rejects.toThrow('NS request failed: 401')
281
+ })
282
+
283
+ it('should throw on 403 forbidden errors', async () => {
284
+ mockFetch.mockResolvedValueOnce({
285
+ ok: false,
286
+ status: 403,
287
+ text: async () => 'Forbidden',
288
+ })
289
+
290
+ await expect(client.listNouns()).rejects.toThrow('NS request failed: 403')
291
+ })
292
+
293
+ it('should handle network errors', async () => {
294
+ mockFetch.mockRejectedValueOnce(new Error('Network error'))
295
+
296
+ // Use listNouns which throws errors (getNoun catches and returns null)
297
+ await expect(client.listNouns()).rejects.toThrow('Network error')
298
+ })
299
+
300
+ it('should handle connection refused errors', async () => {
301
+ mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'))
302
+
303
+ await expect(client.listNouns()).rejects.toThrow('ECONNREFUSED')
304
+ })
305
+
306
+ it('should handle timeout errors', async () => {
307
+ mockFetch.mockRejectedValueOnce(new Error('Request timeout'))
308
+
309
+ await expect(client.create('Post', {})).rejects.toThrow('Request timeout')
310
+ })
311
+
312
+ it('should include error text in thrown error message', async () => {
313
+ mockFetch.mockResolvedValueOnce({
314
+ ok: false,
315
+ status: 422,
316
+ text: async () => 'Validation failed: name is required',
317
+ })
318
+
319
+ await expect(client.defineNoun({ name: '' })).rejects.toThrow(
320
+ 'NS request failed: 422 Validation failed: name is required'
321
+ )
322
+ })
323
+ })
324
+
325
+ // ==================== URL Encoding ====================
326
+
327
+ describe('URL encoding', () => {
328
+ it('should encode special characters in noun names', async () => {
329
+ mockFetch.mockResolvedValueOnce({
330
+ ok: true,
331
+ json: async () => null,
332
+ })
333
+
334
+ try {
335
+ await client.getNoun('Test/Noun')
336
+ } catch {
337
+ // Ignore error
338
+ }
339
+
340
+ expect(mockFetch).toHaveBeenCalledWith(
341
+ expect.stringContaining('Test%2FNoun'),
342
+ expect.any(Object)
343
+ )
344
+ })
345
+
346
+ it('should encode special characters in thing IDs', async () => {
347
+ mockFetch.mockResolvedValueOnce({
348
+ ok: false,
349
+ status: 404,
350
+ text: async () => 'Not found',
351
+ })
352
+
353
+ await client.get('id/with/slashes')
354
+
355
+ expect(mockFetch).toHaveBeenCalledWith(
356
+ expect.stringContaining('id%2Fwith%2Fslashes'),
357
+ expect.any(Object)
358
+ )
359
+ })
360
+
361
+ it('should encode spaces in IDs', async () => {
362
+ mockFetch.mockResolvedValueOnce({
363
+ ok: false,
364
+ status: 404,
365
+ text: async () => 'Not found',
366
+ })
367
+
368
+ await client.get('id with spaces')
369
+
370
+ expect(mockFetch).toHaveBeenCalledWith(
371
+ expect.stringContaining('id%20with%20spaces'),
372
+ expect.any(Object)
373
+ )
374
+ })
375
+
376
+ it('should encode ampersands in IDs', async () => {
377
+ mockFetch.mockResolvedValueOnce({
378
+ ok: false,
379
+ status: 404,
380
+ text: async () => 'Not found',
381
+ })
382
+
383
+ await client.get('id&with&ampersands')
384
+
385
+ expect(mockFetch).toHaveBeenCalledWith(
386
+ expect.stringContaining('id%26with%26ampersands'),
387
+ expect.any(Object)
388
+ )
389
+ })
390
+
391
+ it('should encode question marks in IDs', async () => {
392
+ mockFetch.mockResolvedValueOnce({
393
+ ok: false,
394
+ status: 404,
395
+ text: async () => 'Not found',
396
+ })
397
+
398
+ await client.get('id?with?questions')
399
+
400
+ expect(mockFetch).toHaveBeenCalledWith(
401
+ expect.stringContaining('id%3Fwith%3Fquestions'),
402
+ expect.any(Object)
403
+ )
404
+ })
405
+ })
406
+
407
+ // ==================== Noun Methods ====================
408
+
409
+ describe('Noun methods', () => {
410
+ it('should define a noun', async () => {
411
+ const nounData = {
412
+ name: 'Post',
413
+ singular: 'post',
414
+ plural: 'posts',
415
+ slug: 'post',
416
+ createdAt: new Date().toISOString(),
417
+ }
418
+
419
+ mockFetch.mockResolvedValueOnce({
420
+ ok: true,
421
+ json: async () => nounData,
422
+ })
423
+
424
+ const result = await client.defineNoun({ name: 'Post' })
425
+
426
+ expect(result.name).toBe('Post')
427
+ expect(result.singular).toBe('post')
428
+ })
429
+
430
+ it('should define a noun with description', async () => {
431
+ mockFetch.mockResolvedValueOnce({
432
+ ok: true,
433
+ json: async () => ({
434
+ name: 'Article',
435
+ description: 'A blog article',
436
+ singular: 'article',
437
+ plural: 'articles',
438
+ slug: 'article',
439
+ createdAt: new Date().toISOString(),
440
+ }),
441
+ })
442
+
443
+ await client.defineNoun({ name: 'Article', description: 'A blog article' })
444
+
445
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
446
+ expect(body.description).toBe('A blog article')
447
+ })
448
+
449
+ it('should list all nouns', async () => {
450
+ mockFetch.mockResolvedValueOnce({
451
+ ok: true,
452
+ json: async () => [
453
+ {
454
+ name: 'Post',
455
+ singular: 'post',
456
+ plural: 'posts',
457
+ slug: 'post',
458
+ createdAt: new Date().toISOString(),
459
+ },
460
+ {
461
+ name: 'Author',
462
+ singular: 'author',
463
+ plural: 'authors',
464
+ slug: 'author',
465
+ createdAt: new Date().toISOString(),
466
+ },
467
+ ],
468
+ })
469
+
470
+ const nouns = await client.listNouns()
471
+
472
+ expect(nouns).toHaveLength(2)
473
+ expect(nouns[0].name).toBe('Post')
474
+ expect(nouns[1].name).toBe('Author')
475
+ })
476
+
477
+ it('should return empty array when no nouns exist', async () => {
478
+ mockFetch.mockResolvedValueOnce({
479
+ ok: true,
480
+ json: async () => [],
481
+ })
482
+
483
+ const nouns = await client.listNouns()
484
+ expect(nouns).toHaveLength(0)
485
+ })
486
+ })
487
+
488
+ // ==================== Verb Methods ====================
489
+
490
+ describe('Verb methods', () => {
491
+ it('should define a verb', async () => {
492
+ const verbData = {
493
+ name: 'create',
494
+ action: 'create',
495
+ act: 'creates',
496
+ activity: 'creating',
497
+ event: 'created',
498
+ reverseBy: 'createdBy',
499
+ reverseAt: 'createdAt',
500
+ createdAt: new Date().toISOString(),
501
+ }
502
+
503
+ mockFetch.mockResolvedValueOnce({
504
+ ok: true,
505
+ json: async () => verbData,
506
+ })
507
+
508
+ const result = await client.defineVerb({ name: 'create' })
509
+
510
+ expect(result.name).toBe('create')
511
+ expect(result.event).toBe('created')
512
+ })
513
+
514
+ it('should define a verb with inverse', async () => {
515
+ mockFetch.mockResolvedValueOnce({
516
+ ok: true,
517
+ json: async () => ({
518
+ name: 'publish',
519
+ inverse: 'unpublish',
520
+ action: 'publish',
521
+ act: 'publishes',
522
+ activity: 'publishing',
523
+ event: 'published',
524
+ createdAt: new Date().toISOString(),
525
+ }),
526
+ })
527
+
528
+ await client.defineVerb({ name: 'publish', inverse: 'unpublish' })
529
+
530
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
531
+ expect(body.inverse).toBe('unpublish')
532
+ })
533
+
534
+ it('should get a verb by name', async () => {
535
+ mockFetch.mockResolvedValueOnce({
536
+ ok: true,
537
+ json: async () => ({
538
+ name: 'write',
539
+ action: 'write',
540
+ act: 'writes',
541
+ activity: 'writing',
542
+ event: 'written',
543
+ createdAt: new Date().toISOString(),
544
+ }),
545
+ })
546
+
547
+ const verb = await client.getVerb('write')
548
+
549
+ expect(verb?.name).toBe('write')
550
+ expect(verb?.event).toBe('written')
551
+ })
552
+
553
+ it('should list all verbs', async () => {
554
+ mockFetch.mockResolvedValueOnce({
555
+ ok: true,
556
+ json: async () => [
557
+ {
558
+ name: 'create',
559
+ action: 'create',
560
+ act: 'creates',
561
+ activity: 'creating',
562
+ event: 'created',
563
+ createdAt: new Date().toISOString(),
564
+ },
565
+ {
566
+ name: 'delete',
567
+ action: 'delete',
568
+ act: 'deletes',
569
+ activity: 'deleting',
570
+ event: 'deleted',
571
+ createdAt: new Date().toISOString(),
572
+ },
573
+ ],
574
+ })
575
+
576
+ const verbs = await client.listVerbs()
577
+
578
+ expect(verbs).toHaveLength(2)
579
+ })
580
+ })
581
+
582
+ // ==================== Thing Methods ====================
583
+
584
+ describe('Thing methods', () => {
585
+ it('should create a thing', async () => {
586
+ mockFetch.mockResolvedValueOnce({
587
+ ok: true,
588
+ json: async () => ({
589
+ id: 'thing-123',
590
+ noun: 'Post',
591
+ data: { title: 'Hello World' },
592
+ createdAt: new Date().toISOString(),
593
+ updatedAt: new Date().toISOString(),
594
+ }),
595
+ })
596
+
597
+ const thing = await client.create('Post', { title: 'Hello World' })
598
+
599
+ expect(thing.id).toBe('thing-123')
600
+ expect(thing.noun).toBe('Post')
601
+ expect(thing.data.title).toBe('Hello World')
602
+ })
603
+
604
+ it('should create a thing with custom ID', async () => {
605
+ mockFetch.mockResolvedValueOnce({
606
+ ok: true,
607
+ json: async () => ({
608
+ id: 'custom-id',
609
+ noun: 'Post',
610
+ data: { title: 'Custom' },
611
+ createdAt: new Date().toISOString(),
612
+ updatedAt: new Date().toISOString(),
613
+ }),
614
+ })
615
+
616
+ await client.create('Post', { title: 'Custom' }, 'custom-id')
617
+
618
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
619
+ expect(body.id).toBe('custom-id')
620
+ })
621
+
622
+ it('should get a thing by ID', async () => {
623
+ mockFetch.mockResolvedValueOnce({
624
+ ok: true,
625
+ json: async () => ({
626
+ id: 'thing-123',
627
+ noun: 'Post',
628
+ data: { title: 'Test Post' },
629
+ createdAt: new Date().toISOString(),
630
+ updatedAt: new Date().toISOString(),
631
+ }),
632
+ })
633
+
634
+ const thing = await client.get('thing-123')
635
+
636
+ expect(thing?.id).toBe('thing-123')
637
+ expect(thing?.data.title).toBe('Test Post')
638
+ })
639
+
640
+ it('should list things by noun', async () => {
641
+ mockFetch.mockResolvedValueOnce({
642
+ ok: true,
643
+ json: async () => [
644
+ {
645
+ id: 'post-1',
646
+ noun: 'Post',
647
+ data: { title: 'First' },
648
+ createdAt: new Date().toISOString(),
649
+ updatedAt: new Date().toISOString(),
650
+ },
651
+ {
652
+ id: 'post-2',
653
+ noun: 'Post',
654
+ data: { title: 'Second' },
655
+ createdAt: new Date().toISOString(),
656
+ updatedAt: new Date().toISOString(),
657
+ },
658
+ ],
659
+ })
660
+
661
+ const posts = await client.list('Post')
662
+
663
+ expect(posts).toHaveLength(2)
664
+ expect(mockFetch).toHaveBeenCalledWith(
665
+ expect.stringContaining('noun=Post'),
666
+ expect.any(Object)
667
+ )
668
+ })
669
+
670
+ it('should list things with limit option', async () => {
671
+ mockFetch.mockResolvedValueOnce({
672
+ ok: true,
673
+ json: async () => [],
674
+ })
675
+
676
+ await client.list('Post', { limit: 10 })
677
+
678
+ expect(mockFetch).toHaveBeenCalledWith(
679
+ expect.stringContaining('limit=10'),
680
+ expect.any(Object)
681
+ )
682
+ })
683
+
684
+ it('should list things with offset option', async () => {
685
+ mockFetch.mockResolvedValueOnce({
686
+ ok: true,
687
+ json: async () => [],
688
+ })
689
+
690
+ await client.list('Post', { offset: 20 })
691
+
692
+ expect(mockFetch).toHaveBeenCalledWith(
693
+ expect.stringContaining('offset=20'),
694
+ expect.any(Object)
695
+ )
696
+ })
697
+
698
+ it('should list things with orderBy option', async () => {
699
+ mockFetch.mockResolvedValueOnce({
700
+ ok: true,
701
+ json: async () => [],
702
+ })
703
+
704
+ await client.list('Post', { orderBy: 'createdAt' })
705
+
706
+ expect(mockFetch).toHaveBeenCalledWith(
707
+ expect.stringContaining('orderBy=createdAt'),
708
+ expect.any(Object)
709
+ )
710
+ })
711
+
712
+ it('should list things with order option', async () => {
713
+ mockFetch.mockResolvedValueOnce({
714
+ ok: true,
715
+ json: async () => [],
716
+ })
717
+
718
+ await client.list('Post', { order: 'desc' })
719
+
720
+ expect(mockFetch).toHaveBeenCalledWith(
721
+ expect.stringContaining('order=desc'),
722
+ expect.any(Object)
723
+ )
724
+ })
725
+
726
+ it('should filter results client-side with where option', async () => {
727
+ mockFetch.mockResolvedValueOnce({
728
+ ok: true,
729
+ json: async () => [
730
+ {
731
+ id: 'post-1',
732
+ noun: 'Post',
733
+ data: { title: 'Draft', status: 'draft' },
734
+ createdAt: new Date().toISOString(),
735
+ updatedAt: new Date().toISOString(),
736
+ },
737
+ {
738
+ id: 'post-2',
739
+ noun: 'Post',
740
+ data: { title: 'Published', status: 'published' },
741
+ createdAt: new Date().toISOString(),
742
+ updatedAt: new Date().toISOString(),
743
+ },
744
+ ],
745
+ })
746
+
747
+ const posts = await client.list<{ title: string; status: string }>('Post', {
748
+ where: { status: 'draft' },
749
+ })
750
+
751
+ expect(posts).toHaveLength(1)
752
+ expect(posts[0].data.status).toBe('draft')
753
+ })
754
+
755
+ it('should find things by criteria', async () => {
756
+ mockFetch.mockResolvedValueOnce({
757
+ ok: true,
758
+ json: async () => [
759
+ {
760
+ id: 'post-1',
761
+ noun: 'Post',
762
+ data: { status: 'published' },
763
+ createdAt: new Date().toISOString(),
764
+ updatedAt: new Date().toISOString(),
765
+ },
766
+ ],
767
+ })
768
+
769
+ const posts = await client.find<{ status: string }>('Post', { status: 'published' })
770
+
771
+ expect(posts).toHaveLength(1)
772
+ })
773
+
774
+ it('should update a thing', async () => {
775
+ mockFetch.mockResolvedValueOnce({
776
+ ok: true,
777
+ json: async () => ({
778
+ id: 'post-1',
779
+ noun: 'Post',
780
+ data: { title: 'Updated Title' },
781
+ createdAt: new Date().toISOString(),
782
+ updatedAt: new Date().toISOString(),
783
+ }),
784
+ })
785
+
786
+ const updated = await client.update('post-1', { title: 'Updated Title' })
787
+
788
+ expect(updated.data.title).toBe('Updated Title')
789
+ })
790
+
791
+ it('should delete a thing and return true', async () => {
792
+ mockFetch.mockResolvedValueOnce({
793
+ ok: true,
794
+ json: async () => ({ deleted: true }),
795
+ })
796
+
797
+ const result = await client.delete('post-1')
798
+ expect(result).toBe(true)
799
+ })
800
+
801
+ it('should delete a thing and return false when not found', async () => {
802
+ mockFetch.mockResolvedValueOnce({
803
+ ok: true,
804
+ json: async () => ({ deleted: false }),
805
+ })
806
+
807
+ const result = await client.delete('non-existent')
808
+ expect(result).toBe(false)
809
+ })
810
+
811
+ it('should search things', async () => {
812
+ mockFetch.mockResolvedValueOnce({
813
+ ok: true,
814
+ json: async () => [
815
+ {
816
+ id: 'post-1',
817
+ noun: 'Post',
818
+ data: { title: 'Hello World' },
819
+ createdAt: new Date().toISOString(),
820
+ updatedAt: new Date().toISOString(),
821
+ },
822
+ ],
823
+ })
824
+
825
+ const results = await client.search('Hello')
826
+
827
+ expect(results).toHaveLength(1)
828
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('q=Hello'), expect.any(Object))
829
+ })
830
+
831
+ it('should search things with limit option', async () => {
832
+ mockFetch.mockResolvedValueOnce({
833
+ ok: true,
834
+ json: async () => [],
835
+ })
836
+
837
+ await client.search('test', { limit: 5 })
838
+
839
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('limit=5'), expect.any(Object))
840
+ })
841
+ })
842
+
843
+ // ==================== Action Methods ====================
844
+
845
+ describe('Action methods', () => {
846
+ it('should perform an action', async () => {
847
+ mockFetch.mockResolvedValueOnce({
848
+ ok: true,
849
+ json: async () => ({
850
+ id: 'action-123',
851
+ verb: 'write',
852
+ subject: 'author-1',
853
+ object: 'post-1',
854
+ status: 'completed',
855
+ createdAt: new Date().toISOString(),
856
+ }),
857
+ })
858
+
859
+ const action = await client.perform('write', 'author-1', 'post-1')
860
+
861
+ expect(action.id).toBe('action-123')
862
+ expect(action.verb).toBe('write')
863
+ expect(action.subject).toBe('author-1')
864
+ expect(action.object).toBe('post-1')
865
+ })
866
+
867
+ it('should perform an action with data', async () => {
868
+ mockFetch.mockResolvedValueOnce({
869
+ ok: true,
870
+ json: async () => ({
871
+ id: 'action-123',
872
+ verb: 'publish',
873
+ object: 'post-1',
874
+ data: { publishedBy: 'admin' },
875
+ status: 'completed',
876
+ createdAt: new Date().toISOString(),
877
+ }),
878
+ })
879
+
880
+ await client.perform('publish', undefined, 'post-1', { publishedBy: 'admin' })
881
+
882
+ const body = JSON.parse(mockFetch.mock.calls[0][1].body as string)
883
+ expect(body.data.publishedBy).toBe('admin')
884
+ })
885
+
886
+ it('should perform an action without subject or object', async () => {
887
+ mockFetch.mockResolvedValueOnce({
888
+ ok: true,
889
+ json: async () => ({
890
+ id: 'action-123',
891
+ verb: 'backup',
892
+ status: 'completed',
893
+ createdAt: new Date().toISOString(),
894
+ }),
895
+ })
896
+
897
+ const action = await client.perform('backup')
898
+
899
+ expect(action.verb).toBe('backup')
900
+ expect(action.subject).toBeUndefined()
901
+ expect(action.object).toBeUndefined()
902
+ })
903
+
904
+ it('should get an action by ID', async () => {
905
+ mockFetch.mockResolvedValueOnce({
906
+ ok: true,
907
+ json: async () => ({
908
+ id: 'action-123',
909
+ verb: 'write',
910
+ status: 'completed',
911
+ createdAt: new Date().toISOString(),
912
+ }),
913
+ })
914
+
915
+ const action = await client.getAction('action-123')
916
+
917
+ expect(action?.id).toBe('action-123')
918
+ })
919
+
920
+ it('should list actions', async () => {
921
+ mockFetch.mockResolvedValueOnce({
922
+ ok: true,
923
+ json: async () => [
924
+ {
925
+ id: 'action-1',
926
+ verb: 'write',
927
+ status: 'completed',
928
+ createdAt: new Date().toISOString(),
929
+ },
930
+ {
931
+ id: 'action-2',
932
+ verb: 'publish',
933
+ status: 'completed',
934
+ createdAt: new Date().toISOString(),
935
+ },
936
+ ],
937
+ })
938
+
939
+ const actions = await client.listActions()
940
+
941
+ expect(actions).toHaveLength(2)
942
+ })
943
+
944
+ it('should list actions with verb filter', async () => {
945
+ mockFetch.mockResolvedValueOnce({
946
+ ok: true,
947
+ json: async () => [],
948
+ })
949
+
950
+ await client.listActions({ verb: 'write' })
951
+
952
+ expect(mockFetch).toHaveBeenCalledWith(
953
+ expect.stringContaining('verb=write'),
954
+ expect.any(Object)
955
+ )
956
+ })
957
+
958
+ it('should list actions with subject filter', async () => {
959
+ mockFetch.mockResolvedValueOnce({
960
+ ok: true,
961
+ json: async () => [],
962
+ })
963
+
964
+ await client.listActions({ subject: 'author-1' })
965
+
966
+ expect(mockFetch).toHaveBeenCalledWith(
967
+ expect.stringContaining('subject=author-1'),
968
+ expect.any(Object)
969
+ )
970
+ })
971
+
972
+ it('should list actions with object filter', async () => {
973
+ mockFetch.mockResolvedValueOnce({
974
+ ok: true,
975
+ json: async () => [],
976
+ })
977
+
978
+ await client.listActions({ object: 'post-1' })
979
+
980
+ expect(mockFetch).toHaveBeenCalledWith(
981
+ expect.stringContaining('object=post-1'),
982
+ expect.any(Object)
983
+ )
984
+ })
985
+
986
+ it('should list actions with status filter', async () => {
987
+ mockFetch.mockResolvedValueOnce({
988
+ ok: true,
989
+ json: async () => [],
990
+ })
991
+
992
+ await client.listActions({ status: 'pending' })
993
+
994
+ expect(mockFetch).toHaveBeenCalledWith(
995
+ expect.stringContaining('status=pending'),
996
+ expect.any(Object)
997
+ )
998
+ })
999
+
1000
+ it('should list actions with array status filter (uses first)', async () => {
1001
+ mockFetch.mockResolvedValueOnce({
1002
+ ok: true,
1003
+ json: async () => [],
1004
+ })
1005
+
1006
+ await client.listActions({ status: ['pending', 'active'] })
1007
+
1008
+ expect(mockFetch).toHaveBeenCalledWith(
1009
+ expect.stringContaining('status=pending'),
1010
+ expect.any(Object)
1011
+ )
1012
+ })
1013
+
1014
+ it('should list actions with limit filter', async () => {
1015
+ mockFetch.mockResolvedValueOnce({
1016
+ ok: true,
1017
+ json: async () => [],
1018
+ })
1019
+
1020
+ await client.listActions({ limit: 50 })
1021
+
1022
+ expect(mockFetch).toHaveBeenCalledWith(
1023
+ expect.stringContaining('limit=50'),
1024
+ expect.any(Object)
1025
+ )
1026
+ })
1027
+
1028
+ it('should delete an action and return true', async () => {
1029
+ mockFetch.mockResolvedValueOnce({
1030
+ ok: true,
1031
+ json: async () => ({ deleted: true }),
1032
+ })
1033
+
1034
+ const result = await client.deleteAction('action-1')
1035
+ expect(result).toBe(true)
1036
+ })
1037
+
1038
+ it('should delete an action and return false when not found', async () => {
1039
+ mockFetch.mockResolvedValueOnce({
1040
+ ok: true,
1041
+ json: async () => ({ deleted: false }),
1042
+ })
1043
+
1044
+ const result = await client.deleteAction('non-existent')
1045
+ expect(result).toBe(false)
1046
+ })
1047
+ })
1048
+
1049
+ // ==================== Graph Traversal Methods ====================
1050
+
1051
+ describe('Graph traversal methods', () => {
1052
+ it('should get related things', async () => {
1053
+ mockFetch.mockResolvedValueOnce({
1054
+ ok: true,
1055
+ json: async () => [
1056
+ {
1057
+ id: 'post-1',
1058
+ noun: 'Post',
1059
+ data: { title: 'Post 1' },
1060
+ createdAt: new Date().toISOString(),
1061
+ updatedAt: new Date().toISOString(),
1062
+ },
1063
+ {
1064
+ id: 'post-2',
1065
+ noun: 'Post',
1066
+ data: { title: 'Post 2' },
1067
+ createdAt: new Date().toISOString(),
1068
+ updatedAt: new Date().toISOString(),
1069
+ },
1070
+ ],
1071
+ })
1072
+
1073
+ const related = await client.related('author-1')
1074
+
1075
+ expect(related).toHaveLength(2)
1076
+ expect(mockFetch).toHaveBeenCalledWith(
1077
+ expect.stringContaining('/related/author-1'),
1078
+ expect.any(Object)
1079
+ )
1080
+ })
1081
+
1082
+ it('should get related things with verb filter', async () => {
1083
+ mockFetch.mockResolvedValueOnce({
1084
+ ok: true,
1085
+ json: async () => [],
1086
+ })
1087
+
1088
+ await client.related('author-1', 'write')
1089
+
1090
+ expect(mockFetch).toHaveBeenCalledWith(
1091
+ expect.stringContaining('verb=write'),
1092
+ expect.any(Object)
1093
+ )
1094
+ })
1095
+
1096
+ it('should get related things with outbound direction', async () => {
1097
+ mockFetch.mockResolvedValueOnce({
1098
+ ok: true,
1099
+ json: async () => [],
1100
+ })
1101
+
1102
+ await client.related('author-1', 'write', 'out')
1103
+
1104
+ expect(mockFetch).toHaveBeenCalledWith(
1105
+ expect.stringContaining('direction=out'),
1106
+ expect.any(Object)
1107
+ )
1108
+ })
1109
+
1110
+ it('should get related things with inbound direction', async () => {
1111
+ mockFetch.mockResolvedValueOnce({
1112
+ ok: true,
1113
+ json: async () => [],
1114
+ })
1115
+
1116
+ await client.related('post-1', 'write', 'in')
1117
+
1118
+ expect(mockFetch).toHaveBeenCalledWith(
1119
+ expect.stringContaining('direction=in'),
1120
+ expect.any(Object)
1121
+ )
1122
+ })
1123
+
1124
+ it('should get related things with both direction', async () => {
1125
+ mockFetch.mockResolvedValueOnce({
1126
+ ok: true,
1127
+ json: async () => [],
1128
+ })
1129
+
1130
+ await client.related('thing-1', undefined, 'both')
1131
+
1132
+ expect(mockFetch).toHaveBeenCalledWith(
1133
+ expect.stringContaining('direction=both'),
1134
+ expect.any(Object)
1135
+ )
1136
+ })
1137
+
1138
+ it('should get edges for a thing', async () => {
1139
+ mockFetch.mockResolvedValueOnce({
1140
+ ok: true,
1141
+ json: async () => [
1142
+ {
1143
+ id: 'action-1',
1144
+ verb: 'write',
1145
+ subject: 'author-1',
1146
+ object: 'post-1',
1147
+ status: 'completed',
1148
+ createdAt: new Date().toISOString(),
1149
+ },
1150
+ ],
1151
+ })
1152
+
1153
+ const edges = await client.edges('author-1')
1154
+
1155
+ expect(edges).toHaveLength(1)
1156
+ expect(mockFetch).toHaveBeenCalledWith(
1157
+ expect.stringContaining('/edges/author-1'),
1158
+ expect.any(Object)
1159
+ )
1160
+ })
1161
+
1162
+ it('should get edges with verb filter', async () => {
1163
+ mockFetch.mockResolvedValueOnce({
1164
+ ok: true,
1165
+ json: async () => [],
1166
+ })
1167
+
1168
+ await client.edges('author-1', 'write')
1169
+
1170
+ expect(mockFetch).toHaveBeenCalledWith(
1171
+ expect.stringContaining('verb=write'),
1172
+ expect.any(Object)
1173
+ )
1174
+ })
1175
+
1176
+ it('should get edges with direction', async () => {
1177
+ mockFetch.mockResolvedValueOnce({
1178
+ ok: true,
1179
+ json: async () => [],
1180
+ })
1181
+
1182
+ await client.edges('author-1', undefined, 'out')
1183
+
1184
+ expect(mockFetch).toHaveBeenCalledWith(
1185
+ expect.stringContaining('direction=out'),
1186
+ expect.any(Object)
1187
+ )
1188
+ })
1189
+ })
1190
+
1191
+ // ==================== Lifecycle Methods ====================
1192
+
1193
+ describe('Lifecycle methods', () => {
1194
+ it('should have a close method that does nothing', async () => {
1195
+ await expect(client.close()).resolves.toBeUndefined()
1196
+ })
1197
+
1198
+ it('should be callable multiple times without error', async () => {
1199
+ await client.close()
1200
+ await client.close()
1201
+ await expect(client.close()).resolves.toBeUndefined()
1202
+ })
1203
+ })
1204
+
1205
+ // ==================== Factory Function ====================
1206
+
1207
+ describe('createNSClient factory', () => {
1208
+ it('should create an NSClient instance', () => {
1209
+ const factoryClient = createNSClient({
1210
+ baseUrl: 'https://example.com',
1211
+ namespace: 'test',
1212
+ fetch: mockFetch as typeof fetch,
1213
+ })
1214
+
1215
+ expect(factoryClient).toBeDefined()
1216
+ expect(typeof factoryClient.defineNoun).toBe('function')
1217
+ expect(typeof factoryClient.create).toBe('function')
1218
+ })
1219
+
1220
+ it('should return a DigitalObjectsProvider compatible object', async () => {
1221
+ mockFetch.mockResolvedValue({
1222
+ ok: true,
1223
+ json: async () => [],
1224
+ })
1225
+
1226
+ const provider = createNSClient({
1227
+ baseUrl: 'https://example.com',
1228
+ fetch: mockFetch as typeof fetch,
1229
+ })
1230
+
1231
+ // Verify all DigitalObjectsProvider methods exist
1232
+ expect(provider.defineNoun).toBeDefined()
1233
+ expect(provider.getNoun).toBeDefined()
1234
+ expect(provider.listNouns).toBeDefined()
1235
+ expect(provider.defineVerb).toBeDefined()
1236
+ expect(provider.getVerb).toBeDefined()
1237
+ expect(provider.listVerbs).toBeDefined()
1238
+ expect(provider.create).toBeDefined()
1239
+ expect(provider.get).toBeDefined()
1240
+ expect(provider.list).toBeDefined()
1241
+ expect(provider.find).toBeDefined()
1242
+ expect(provider.update).toBeDefined()
1243
+ expect(provider.delete).toBeDefined()
1244
+ expect(provider.search).toBeDefined()
1245
+ expect(provider.perform).toBeDefined()
1246
+ expect(provider.getAction).toBeDefined()
1247
+ expect(provider.listActions).toBeDefined()
1248
+ expect(provider.deleteAction).toBeDefined()
1249
+ expect(provider.related).toBeDefined()
1250
+ expect(provider.edges).toBeDefined()
1251
+ })
1252
+ })
1253
+
1254
+ // ==================== Edge Cases ====================
1255
+
1256
+ describe('Edge cases', () => {
1257
+ it('should handle empty response body for list operations', async () => {
1258
+ mockFetch.mockResolvedValueOnce({
1259
+ ok: true,
1260
+ json: async () => [],
1261
+ })
1262
+
1263
+ const result = await client.listNouns()
1264
+ expect(result).toEqual([])
1265
+ })
1266
+
1267
+ it('should handle responses with extra fields', async () => {
1268
+ mockFetch.mockResolvedValueOnce({
1269
+ ok: true,
1270
+ json: async () => ({
1271
+ name: 'Test',
1272
+ singular: 'test',
1273
+ plural: 'tests',
1274
+ slug: 'test',
1275
+ createdAt: new Date().toISOString(),
1276
+ extraField: 'should be ignored by TypeScript but passed through',
1277
+ }),
1278
+ })
1279
+
1280
+ const noun = await client.getNoun('Test')
1281
+ expect(noun?.name).toBe('Test')
1282
+ })
1283
+
1284
+ it('should handle Unicode characters in IDs', async () => {
1285
+ mockFetch.mockResolvedValueOnce({
1286
+ ok: false,
1287
+ status: 404,
1288
+ text: async () => 'Not found',
1289
+ })
1290
+
1291
+ await client.get('id-with-unicode-\u4e2d\u6587')
1292
+
1293
+ // URLSearchParams encodes Unicode
1294
+ expect(mockFetch).toHaveBeenCalled()
1295
+ })
1296
+
1297
+ it('should handle very long IDs', async () => {
1298
+ const longId = 'a'.repeat(1000)
1299
+
1300
+ mockFetch.mockResolvedValueOnce({
1301
+ ok: false,
1302
+ status: 404,
1303
+ text: async () => 'Not found',
1304
+ })
1305
+
1306
+ await client.get(longId)
1307
+
1308
+ expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining(longId), expect.any(Object))
1309
+ })
1310
+
1311
+ it('should handle concurrent requests', async () => {
1312
+ mockFetch.mockResolvedValue({
1313
+ ok: true,
1314
+ json: async () => ({
1315
+ name: 'Test',
1316
+ singular: 'test',
1317
+ plural: 'tests',
1318
+ slug: 'test',
1319
+ createdAt: new Date().toISOString(),
1320
+ }),
1321
+ })
1322
+
1323
+ const promises = [client.getNoun('Test1'), client.getNoun('Test2'), client.getNoun('Test3')]
1324
+
1325
+ const results = await Promise.all(promises)
1326
+
1327
+ expect(results).toHaveLength(3)
1328
+ expect(mockFetch).toHaveBeenCalledTimes(3)
1329
+ })
1330
+
1331
+ it('should handle null data in response', async () => {
1332
+ mockFetch.mockResolvedValueOnce({
1333
+ ok: true,
1334
+ json: async () => ({
1335
+ id: 'thing-1',
1336
+ noun: 'Post',
1337
+ data: null,
1338
+ createdAt: new Date().toISOString(),
1339
+ updatedAt: new Date().toISOString(),
1340
+ }),
1341
+ })
1342
+
1343
+ const thing = await client.get('thing-1')
1344
+ expect(thing?.data).toBeNull()
1345
+ })
1346
+
1347
+ it('should handle undefined optional parameters in listActions', async () => {
1348
+ mockFetch.mockResolvedValueOnce({
1349
+ ok: true,
1350
+ json: async () => [],
1351
+ })
1352
+
1353
+ await client.listActions({})
1354
+
1355
+ // Should not include undefined params in URL
1356
+ const calledUrl = mockFetch.mock.calls[0][0] as string
1357
+ expect(calledUrl).not.toContain('undefined')
1358
+ })
1359
+ })
1360
+ })