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,401 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ NounDefinitionSchema,
4
+ VerbDefinitionSchema,
5
+ CreateThingSchema,
6
+ UpdateThingSchema,
7
+ PerformActionSchema,
8
+ BatchCreateThingsSchema,
9
+ BatchUpdateThingsSchema,
10
+ BatchDeleteThingsSchema,
11
+ BatchPerformActionsSchema,
12
+ } from '../src/http-schemas.js'
13
+ import { ZodError } from 'zod'
14
+
15
+ describe('HTTP Request Body Validation', () => {
16
+ describe('NounDefinitionSchema', () => {
17
+ it('should accept valid noun definition', () => {
18
+ const result = NounDefinitionSchema.parse({
19
+ name: 'User',
20
+ singular: 'user',
21
+ plural: 'users',
22
+ description: 'A user account',
23
+ schema: { email: 'string' },
24
+ })
25
+
26
+ expect(result.name).toBe('User')
27
+ expect(result.singular).toBe('user')
28
+ expect(result.plural).toBe('users')
29
+ })
30
+
31
+ it('should accept minimal noun definition', () => {
32
+ const result = NounDefinitionSchema.parse({ name: 'Post' })
33
+
34
+ expect(result.name).toBe('Post')
35
+ expect(result.singular).toBeUndefined()
36
+ })
37
+
38
+ it('should reject empty name', () => {
39
+ expect(() => NounDefinitionSchema.parse({ name: '' })).toThrow(ZodError)
40
+ })
41
+
42
+ it('should reject missing name', () => {
43
+ expect(() => NounDefinitionSchema.parse({})).toThrow(ZodError)
44
+ })
45
+
46
+ it('should reject wrong type for name', () => {
47
+ expect(() => NounDefinitionSchema.parse({ name: 123 })).toThrow(ZodError)
48
+ })
49
+
50
+ it('should provide detailed error for missing name', () => {
51
+ try {
52
+ NounDefinitionSchema.parse({})
53
+ expect.fail('Should have thrown')
54
+ } catch (error) {
55
+ expect(error).toBeInstanceOf(ZodError)
56
+ const zodError = error as ZodError
57
+ expect(zodError.errors[0].path).toContain('name')
58
+ }
59
+ })
60
+ })
61
+
62
+ describe('VerbDefinitionSchema', () => {
63
+ it('should accept valid verb definition', () => {
64
+ const result = VerbDefinitionSchema.parse({
65
+ name: 'create',
66
+ action: 'create',
67
+ act: 'creates',
68
+ activity: 'creating',
69
+ event: 'created',
70
+ })
71
+
72
+ expect(result.name).toBe('create')
73
+ expect(result.action).toBe('create')
74
+ })
75
+
76
+ it('should accept minimal verb definition', () => {
77
+ const result = VerbDefinitionSchema.parse({ name: 'follow' })
78
+
79
+ expect(result.name).toBe('follow')
80
+ })
81
+
82
+ it('should reject empty name', () => {
83
+ expect(() => VerbDefinitionSchema.parse({ name: '' })).toThrow(ZodError)
84
+ })
85
+
86
+ it('should reject missing name', () => {
87
+ expect(() => VerbDefinitionSchema.parse({})).toThrow(ZodError)
88
+ })
89
+ })
90
+
91
+ describe('CreateThingSchema', () => {
92
+ it('should accept valid thing creation', () => {
93
+ const result = CreateThingSchema.parse({
94
+ noun: 'User',
95
+ data: { name: 'Alice', email: 'alice@example.com' },
96
+ })
97
+
98
+ expect(result.noun).toBe('User')
99
+ expect(result.data.name).toBe('Alice')
100
+ })
101
+
102
+ it('should accept thing creation with custom id', () => {
103
+ const result = CreateThingSchema.parse({
104
+ noun: 'User',
105
+ data: { name: 'Bob' },
106
+ id: 'user-123',
107
+ })
108
+
109
+ expect(result.id).toBe('user-123')
110
+ })
111
+
112
+ it('should reject empty noun', () => {
113
+ expect(() =>
114
+ CreateThingSchema.parse({
115
+ noun: '',
116
+ data: { name: 'Alice' },
117
+ })
118
+ ).toThrow(ZodError)
119
+ })
120
+
121
+ it('should reject missing noun', () => {
122
+ expect(() =>
123
+ CreateThingSchema.parse({
124
+ data: { name: 'Alice' },
125
+ })
126
+ ).toThrow(ZodError)
127
+ })
128
+
129
+ it('should reject missing data', () => {
130
+ expect(() =>
131
+ CreateThingSchema.parse({
132
+ noun: 'User',
133
+ })
134
+ ).toThrow(ZodError)
135
+ })
136
+
137
+ it('should reject non-object data', () => {
138
+ expect(() =>
139
+ CreateThingSchema.parse({
140
+ noun: 'User',
141
+ data: 'not an object',
142
+ })
143
+ ).toThrow(ZodError)
144
+ })
145
+ })
146
+
147
+ describe('UpdateThingSchema', () => {
148
+ it('should accept valid update', () => {
149
+ const result = UpdateThingSchema.parse({
150
+ data: { name: 'Updated Name' },
151
+ })
152
+
153
+ expect(result.data.name).toBe('Updated Name')
154
+ })
155
+
156
+ it('should accept empty data object', () => {
157
+ const result = UpdateThingSchema.parse({ data: {} })
158
+
159
+ expect(result.data).toEqual({})
160
+ })
161
+
162
+ it('should reject missing data', () => {
163
+ expect(() => UpdateThingSchema.parse({})).toThrow(ZodError)
164
+ })
165
+
166
+ it('should reject non-object data', () => {
167
+ expect(() =>
168
+ UpdateThingSchema.parse({
169
+ data: ['array', 'not', 'object'],
170
+ })
171
+ ).toThrow(ZodError)
172
+ })
173
+ })
174
+
175
+ describe('PerformActionSchema', () => {
176
+ it('should accept valid action', () => {
177
+ const result = PerformActionSchema.parse({
178
+ verb: 'follow',
179
+ subject: 'user-1',
180
+ object: 'user-2',
181
+ data: { timestamp: Date.now() },
182
+ })
183
+
184
+ expect(result.verb).toBe('follow')
185
+ expect(result.subject).toBe('user-1')
186
+ expect(result.object).toBe('user-2')
187
+ })
188
+
189
+ it('should accept action without subject/object', () => {
190
+ const result = PerformActionSchema.parse({
191
+ verb: 'ping',
192
+ })
193
+
194
+ expect(result.verb).toBe('ping')
195
+ expect(result.subject).toBeUndefined()
196
+ })
197
+
198
+ it('should reject empty verb', () => {
199
+ expect(() =>
200
+ PerformActionSchema.parse({
201
+ verb: '',
202
+ })
203
+ ).toThrow(ZodError)
204
+ })
205
+
206
+ it('should reject missing verb', () => {
207
+ expect(() =>
208
+ PerformActionSchema.parse({
209
+ subject: 'user-1',
210
+ })
211
+ ).toThrow(ZodError)
212
+ })
213
+ })
214
+
215
+ describe('BatchCreateThingsSchema', () => {
216
+ it('should accept valid batch create', () => {
217
+ const result = BatchCreateThingsSchema.parse({
218
+ noun: 'User',
219
+ items: [{ name: 'Alice' }, { name: 'Bob' }],
220
+ })
221
+
222
+ expect(result.noun).toBe('User')
223
+ expect(result.items).toHaveLength(2)
224
+ })
225
+
226
+ it('should accept empty items array', () => {
227
+ const result = BatchCreateThingsSchema.parse({
228
+ noun: 'User',
229
+ items: [],
230
+ })
231
+
232
+ expect(result.items).toHaveLength(0)
233
+ })
234
+
235
+ it('should reject missing noun', () => {
236
+ expect(() =>
237
+ BatchCreateThingsSchema.parse({
238
+ items: [{ name: 'Alice' }],
239
+ })
240
+ ).toThrow(ZodError)
241
+ })
242
+
243
+ it('should reject missing items', () => {
244
+ expect(() =>
245
+ BatchCreateThingsSchema.parse({
246
+ noun: 'User',
247
+ })
248
+ ).toThrow(ZodError)
249
+ })
250
+
251
+ it('should reject non-array items', () => {
252
+ expect(() =>
253
+ BatchCreateThingsSchema.parse({
254
+ noun: 'User',
255
+ items: { name: 'Alice' },
256
+ })
257
+ ).toThrow(ZodError)
258
+ })
259
+ })
260
+
261
+ describe('BatchUpdateThingsSchema', () => {
262
+ it('should accept valid batch update', () => {
263
+ const result = BatchUpdateThingsSchema.parse({
264
+ updates: [
265
+ { id: 'user-1', data: { name: 'Updated Alice' } },
266
+ { id: 'user-2', data: { name: 'Updated Bob' } },
267
+ ],
268
+ })
269
+
270
+ expect(result.updates).toHaveLength(2)
271
+ expect(result.updates[0].id).toBe('user-1')
272
+ })
273
+
274
+ it('should reject missing updates', () => {
275
+ expect(() => BatchUpdateThingsSchema.parse({})).toThrow(ZodError)
276
+ })
277
+
278
+ it('should reject updates with empty id', () => {
279
+ expect(() =>
280
+ BatchUpdateThingsSchema.parse({
281
+ updates: [{ id: '', data: { name: 'Test' } }],
282
+ })
283
+ ).toThrow(ZodError)
284
+ })
285
+
286
+ it('should reject updates missing data', () => {
287
+ expect(() =>
288
+ BatchUpdateThingsSchema.parse({
289
+ updates: [{ id: 'user-1' }],
290
+ })
291
+ ).toThrow(ZodError)
292
+ })
293
+ })
294
+
295
+ describe('BatchDeleteThingsSchema', () => {
296
+ it('should accept valid batch delete', () => {
297
+ const result = BatchDeleteThingsSchema.parse({
298
+ ids: ['user-1', 'user-2', 'user-3'],
299
+ })
300
+
301
+ expect(result.ids).toHaveLength(3)
302
+ })
303
+
304
+ it('should reject missing ids', () => {
305
+ expect(() => BatchDeleteThingsSchema.parse({})).toThrow(ZodError)
306
+ })
307
+
308
+ it('should reject ids with empty strings', () => {
309
+ expect(() =>
310
+ BatchDeleteThingsSchema.parse({
311
+ ids: ['user-1', ''],
312
+ })
313
+ ).toThrow(ZodError)
314
+ })
315
+
316
+ it('should reject non-array ids', () => {
317
+ expect(() =>
318
+ BatchDeleteThingsSchema.parse({
319
+ ids: 'user-1',
320
+ })
321
+ ).toThrow(ZodError)
322
+ })
323
+ })
324
+
325
+ describe('BatchPerformActionsSchema', () => {
326
+ it('should accept valid batch actions', () => {
327
+ const result = BatchPerformActionsSchema.parse({
328
+ actions: [
329
+ { verb: 'follow', subject: 'user-1', object: 'user-2' },
330
+ { verb: 'like', subject: 'user-1', object: 'post-1' },
331
+ ],
332
+ })
333
+
334
+ expect(result.actions).toHaveLength(2)
335
+ expect(result.actions[0].verb).toBe('follow')
336
+ })
337
+
338
+ it('should reject missing actions', () => {
339
+ expect(() => BatchPerformActionsSchema.parse({})).toThrow(ZodError)
340
+ })
341
+
342
+ it('should reject actions with empty verb', () => {
343
+ expect(() =>
344
+ BatchPerformActionsSchema.parse({
345
+ actions: [{ verb: '' }],
346
+ })
347
+ ).toThrow(ZodError)
348
+ })
349
+
350
+ it('should reject non-array actions', () => {
351
+ expect(() =>
352
+ BatchPerformActionsSchema.parse({
353
+ actions: { verb: 'follow' },
354
+ })
355
+ ).toThrow(ZodError)
356
+ })
357
+ })
358
+
359
+ describe('Error Message Format', () => {
360
+ it('should provide path to invalid field', () => {
361
+ try {
362
+ CreateThingSchema.parse({
363
+ noun: 'User',
364
+ data: 'invalid',
365
+ })
366
+ expect.fail('Should have thrown')
367
+ } catch (error) {
368
+ const zodError = error as ZodError
369
+ expect(zodError.errors[0].path).toContain('data')
370
+ }
371
+ })
372
+
373
+ it('should provide helpful error codes', () => {
374
+ try {
375
+ NounDefinitionSchema.parse({ name: '' })
376
+ expect.fail('Should have thrown')
377
+ } catch (error) {
378
+ const zodError = error as ZodError
379
+ expect(zodError.errors[0].code).toBe('too_small')
380
+ }
381
+ })
382
+
383
+ it('should provide error for nested batch update issues', () => {
384
+ try {
385
+ BatchUpdateThingsSchema.parse({
386
+ updates: [
387
+ { id: 'valid', data: { name: 'test' } },
388
+ { id: '', data: { name: 'test' } },
389
+ ],
390
+ })
391
+ expect.fail('Should have thrown')
392
+ } catch (error) {
393
+ const zodError = error as ZodError
394
+ // Should indicate the path to the invalid nested field
395
+ expect(zodError.errors[0].path).toContain('updates')
396
+ expect(zodError.errors[0].path).toContain(1) // index of invalid item
397
+ expect(zodError.errors[0].path).toContain('id')
398
+ }
399
+ })
400
+ })
401
+ })
@@ -0,0 +1,208 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { NSClient } from '../src/ns-client.js'
3
+ import { NotFoundError, DigitalObjectsError } from '../src/errors.js'
4
+
5
+ /**
6
+ * Tests for NSClient error handling
7
+ *
8
+ * These tests verify that NSClient properly distinguishes between:
9
+ * - 404 errors (return null - expected "not found" behavior)
10
+ * - 500 errors (throw ServerError - server problems should propagate)
11
+ * - Network errors (throw NetworkError - connection issues should propagate)
12
+ */
13
+ describe('NSClient Error Handling', () => {
14
+ let mockFetch: ReturnType<typeof vi.fn>
15
+ let client: NSClient
16
+
17
+ beforeEach(() => {
18
+ mockFetch = vi.fn()
19
+ client = new NSClient({
20
+ baseUrl: 'https://ns.example.com',
21
+ namespace: 'test',
22
+ fetch: mockFetch,
23
+ })
24
+ })
25
+
26
+ afterEach(() => {
27
+ vi.restoreAllMocks()
28
+ })
29
+
30
+ describe('getNoun', () => {
31
+ it('should return null for 404 responses', async () => {
32
+ mockFetch.mockResolvedValueOnce(
33
+ new Response(JSON.stringify({ error: 'NOT_FOUND', message: 'Noun not found' }), {
34
+ status: 404,
35
+ statusText: 'Not Found',
36
+ })
37
+ )
38
+
39
+ const result = await client.getNoun('NonExistent')
40
+
41
+ expect(result).toBeNull()
42
+ })
43
+
44
+ it('should throw ServerError for 500 responses', async () => {
45
+ mockFetch.mockResolvedValue(
46
+ new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: 'Database error' }), {
47
+ status: 500,
48
+ statusText: 'Internal Server Error',
49
+ })
50
+ )
51
+
52
+ // Should throw, not return null
53
+ await expect(client.getNoun('Post')).rejects.toThrow(DigitalObjectsError)
54
+
55
+ // Re-create mock for second assertion
56
+ mockFetch.mockResolvedValueOnce(
57
+ new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: 'Database error' }), {
58
+ status: 500,
59
+ statusText: 'Internal Server Error',
60
+ })
61
+ )
62
+ await expect(client.getNoun('Post')).rejects.toMatchObject({
63
+ statusCode: 500,
64
+ })
65
+ })
66
+
67
+ it('should throw NetworkError for network timeouts', async () => {
68
+ const timeoutError = new Error('fetch failed')
69
+ timeoutError.name = 'AbortError'
70
+ mockFetch.mockRejectedValueOnce(timeoutError)
71
+
72
+ // Should throw NetworkError, not return null
73
+ await expect(client.getNoun('Post')).rejects.toThrow()
74
+ })
75
+
76
+ it('should throw NetworkError for connection refused', async () => {
77
+ mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'))
78
+
79
+ // Should throw NetworkError, not return null
80
+ await expect(client.getNoun('Post')).rejects.toThrow()
81
+ })
82
+
83
+ it('should throw for 503 Service Unavailable', async () => {
84
+ mockFetch.mockResolvedValueOnce(
85
+ new Response(
86
+ JSON.stringify({ error: 'UNAVAILABLE', message: 'Service temporarily unavailable' }),
87
+ {
88
+ status: 503,
89
+ statusText: 'Service Unavailable',
90
+ }
91
+ )
92
+ )
93
+
94
+ // Should throw, not return null
95
+ await expect(client.getNoun('Post')).rejects.toThrow()
96
+ })
97
+ })
98
+
99
+ describe('getVerb', () => {
100
+ it('should return null for 404 responses', async () => {
101
+ mockFetch.mockResolvedValueOnce(
102
+ new Response(JSON.stringify({ error: 'NOT_FOUND', message: 'Verb not found' }), {
103
+ status: 404,
104
+ statusText: 'Not Found',
105
+ })
106
+ )
107
+
108
+ const result = await client.getVerb('NonExistent')
109
+
110
+ expect(result).toBeNull()
111
+ })
112
+
113
+ it('should throw for 500 responses', async () => {
114
+ mockFetch.mockResolvedValueOnce(
115
+ new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: 'Database error' }), {
116
+ status: 500,
117
+ statusText: 'Internal Server Error',
118
+ })
119
+ )
120
+
121
+ await expect(client.getVerb('Like')).rejects.toThrow()
122
+ })
123
+ })
124
+
125
+ describe('get (Thing)', () => {
126
+ it('should return null for 404 responses', async () => {
127
+ mockFetch.mockResolvedValueOnce(
128
+ new Response(JSON.stringify({ error: 'NOT_FOUND', message: 'Thing not found' }), {
129
+ status: 404,
130
+ statusText: 'Not Found',
131
+ })
132
+ )
133
+
134
+ const result = await client.get('non-existent-id')
135
+
136
+ expect(result).toBeNull()
137
+ })
138
+
139
+ it('should throw for 500 responses', async () => {
140
+ mockFetch.mockResolvedValueOnce(
141
+ new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: 'Database error' }), {
142
+ status: 500,
143
+ statusText: 'Internal Server Error',
144
+ })
145
+ )
146
+
147
+ await expect(client.get('some-id')).rejects.toThrow()
148
+ })
149
+
150
+ it('should throw NetworkError for network failures', async () => {
151
+ mockFetch.mockRejectedValueOnce(new TypeError('Failed to fetch'))
152
+
153
+ await expect(client.get('some-id')).rejects.toThrow()
154
+ })
155
+ })
156
+
157
+ describe('getAction', () => {
158
+ it('should return null for 404 responses', async () => {
159
+ mockFetch.mockResolvedValueOnce(
160
+ new Response(JSON.stringify({ error: 'NOT_FOUND', message: 'Action not found' }), {
161
+ status: 404,
162
+ statusText: 'Not Found',
163
+ })
164
+ )
165
+
166
+ const result = await client.getAction('non-existent-action')
167
+
168
+ expect(result).toBeNull()
169
+ })
170
+
171
+ it('should throw for 500 responses', async () => {
172
+ mockFetch.mockResolvedValueOnce(
173
+ new Response(JSON.stringify({ error: 'INTERNAL_ERROR', message: 'Database error' }), {
174
+ status: 500,
175
+ statusText: 'Internal Server Error',
176
+ })
177
+ )
178
+
179
+ await expect(client.getAction('some-action')).rejects.toThrow()
180
+ })
181
+ })
182
+
183
+ describe('Error classification', () => {
184
+ it('should throw NotFoundError specifically for 404', async () => {
185
+ // Setup: first call for setup, second for assertion
186
+ mockFetch.mockResolvedValue(
187
+ new Response(JSON.stringify({ error: 'NOT_FOUND', message: 'Not found' }), {
188
+ status: 404,
189
+ })
190
+ )
191
+
192
+ // 404 should return null (not throw)
193
+ const result = await client.getNoun('Missing')
194
+ expect(result).toBeNull()
195
+ })
196
+
197
+ it('should preserve error message from server for non-404 errors', async () => {
198
+ mockFetch.mockResolvedValueOnce(
199
+ new Response(JSON.stringify({ error: 'RATE_LIMITED', message: 'Too many requests' }), {
200
+ status: 429,
201
+ statusText: 'Too Many Requests',
202
+ })
203
+ )
204
+
205
+ await expect(client.getNoun('Post')).rejects.toThrow(/429|Too many requests|rate/i)
206
+ })
207
+ })
208
+ })