digital-products 2.1.3 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +2 -0
  3. package/dist/api.js +7 -7
  4. package/dist/api.js.map +1 -1
  5. package/dist/app.js +6 -6
  6. package/dist/app.js.map +1 -1
  7. package/dist/client.d.ts +157 -0
  8. package/dist/client.d.ts.map +1 -0
  9. package/dist/client.js +69 -0
  10. package/dist/client.js.map +1 -0
  11. package/dist/content.js +7 -7
  12. package/dist/content.js.map +1 -1
  13. package/dist/data.d.ts.map +1 -1
  14. package/dist/data.js +6 -6
  15. package/dist/data.js.map +1 -1
  16. package/dist/dataset.js +5 -5
  17. package/dist/dataset.js.map +1 -1
  18. package/dist/index.d.ts +92 -13
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +139 -15
  21. package/dist/index.js.map +1 -1
  22. package/dist/mcp.d.ts +1 -1
  23. package/dist/mcp.d.ts.map +1 -1
  24. package/dist/mcp.js +17 -10
  25. package/dist/mcp.js.map +1 -1
  26. package/dist/product.js +2 -2
  27. package/dist/product.js.map +1 -1
  28. package/dist/sdk.d.ts.map +1 -1
  29. package/dist/sdk.js +52 -16
  30. package/dist/sdk.js.map +1 -1
  31. package/dist/site.d.ts.map +1 -1
  32. package/dist/site.js +12 -8
  33. package/dist/site.js.map +1 -1
  34. package/dist/types.d.ts +830 -12
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js +495 -2
  37. package/dist/types.js.map +1 -1
  38. package/dist/worker.d.ts +205 -0
  39. package/dist/worker.d.ts.map +1 -0
  40. package/dist/worker.js +356 -0
  41. package/dist/worker.js.map +1 -0
  42. package/package.json +29 -13
  43. package/src/api.ts +7 -7
  44. package/src/app.ts +6 -6
  45. package/src/client.ts +192 -0
  46. package/src/content.ts +7 -7
  47. package/src/data.ts +12 -7
  48. package/src/dataset.ts +5 -5
  49. package/src/index.ts +151 -15
  50. package/src/mcp.ts +18 -11
  51. package/src/product.ts +2 -2
  52. package/src/sdk.ts +54 -15
  53. package/src/site.ts +12 -8
  54. package/src/types.ts +821 -12
  55. package/src/worker.ts +525 -0
  56. package/test/product.test.ts +53 -198
  57. package/test/unified-types.test.ts +589 -0
  58. package/test/worker.test.ts +912 -0
  59. package/vitest.config.ts +42 -0
  60. package/wrangler.jsonc +36 -0
  61. package/.turbo/turbo-build.log +0 -5
  62. package/LICENSE +0 -21
  63. package/dist/features/define.d.ts +0 -63
  64. package/dist/features/define.d.ts.map +0 -1
  65. package/dist/features/define.js +0 -72
  66. package/dist/features/define.js.map +0 -1
  67. package/dist/features/flags.d.ts +0 -98
  68. package/dist/features/flags.d.ts.map +0 -1
  69. package/dist/features/flags.js +0 -145
  70. package/dist/features/flags.js.map +0 -1
  71. package/dist/features/toggles.d.ts +0 -75
  72. package/dist/features/toggles.d.ts.map +0 -1
  73. package/dist/features/toggles.js +0 -107
  74. package/dist/features/toggles.js.map +0 -1
  75. package/dist/tiers/define.d.ts +0 -63
  76. package/dist/tiers/define.d.ts.map +0 -1
  77. package/dist/tiers/define.js +0 -78
  78. package/dist/tiers/define.js.map +0 -1
  79. package/dist/tiers/entitlements.d.ts +0 -94
  80. package/dist/tiers/entitlements.d.ts.map +0 -1
  81. package/dist/tiers/entitlements.js +0 -94
  82. package/dist/tiers/entitlements.js.map +0 -1
  83. package/src/api.js +0 -128
  84. package/src/app.js +0 -106
  85. package/src/content.js +0 -77
  86. package/src/data.js +0 -106
  87. package/src/dataset.js +0 -49
  88. package/src/entities/ai.js +0 -858
  89. package/src/entities/content.js +0 -783
  90. package/src/entities/index.js +0 -88
  91. package/src/entities/interfaces.js +0 -929
  92. package/src/entities/lifecycle.js +0 -803
  93. package/src/entities/products.js +0 -797
  94. package/src/entities/web.js +0 -657
  95. package/src/features/define.ts +0 -130
  96. package/src/features/flags.ts +0 -247
  97. package/src/features/toggles.ts +0 -189
  98. package/src/index.js +0 -35
  99. package/src/mcp.js +0 -139
  100. package/src/pricing/billing.ts +0 -386
  101. package/src/pricing/plans.ts +0 -214
  102. package/src/product.js +0 -53
  103. package/src/registry.js +0 -31
  104. package/src/sdk.js +0 -127
  105. package/src/site.js +0 -112
  106. package/src/tiers/define.ts +0 -137
  107. package/src/tiers/entitlements.ts +0 -201
  108. package/src/types.js +0 -4
  109. package/test/analytics/events.test.ts +0 -319
  110. package/test/analytics/experiments.test.ts +0 -327
  111. package/test/features/define.test.ts +0 -187
  112. package/test/features/flags.test.ts +0 -259
  113. package/test/features/toggles.test.ts +0 -178
  114. package/test/lifecycle/stages.test.ts +0 -233
  115. package/test/lifecycle/transitions.test.ts +0 -207
  116. package/test/onboarding/flows.test.ts +0 -307
  117. package/test/pricing/billing.test.ts +0 -287
  118. package/test/pricing/plans.test.ts +0 -307
  119. package/test/roadmap/milestones.test.ts +0 -231
  120. package/test/roadmap/priorities.test.ts +0 -239
  121. package/test/tiers/define.test.ts +0 -192
  122. package/test/tiers/entitlements.test.ts +0 -220
@@ -0,0 +1,912 @@
1
+ /**
2
+ * Worker Export Tests (RED phase)
3
+ *
4
+ * Tests for the /worker export that provides ProductService (WorkerEntrypoint)
5
+ * with a connect() method that returns ProductServiceCore (RpcTarget).
6
+ *
7
+ * IMPORTANT: NO MOCKS - These tests run against real Durable Objects with SQLite persistence
8
+ * using @cloudflare/vitest-pool-workers and miniflare.
9
+ *
10
+ * These tests should FAIL initially because src/worker.ts doesn't exist yet.
11
+ */
12
+
13
+ import { describe, it, expect, beforeEach } from 'vitest'
14
+ import { env } from 'cloudflare:test'
15
+
16
+ // Types for the expected service interface
17
+ interface ProductData {
18
+ id: string
19
+ name: string
20
+ description: string
21
+ version: string
22
+ status: 'draft' | 'active' | 'deprecated' | 'archived'
23
+ type?: string
24
+ features?: FeatureData[]
25
+ metadata?: Record<string, unknown>
26
+ tags?: string[]
27
+ createdAt: Date
28
+ updatedAt: Date
29
+ }
30
+
31
+ interface FeatureData {
32
+ id: string
33
+ name: string
34
+ description: string
35
+ status: 'draft' | 'beta' | 'ga' | 'deprecated'
36
+ productId: string
37
+ metadata?: Record<string, unknown>
38
+ createdAt: Date
39
+ updatedAt: Date
40
+ }
41
+
42
+ interface VersionData {
43
+ id: string
44
+ productId: string
45
+ version: string
46
+ changelog?: string
47
+ status: 'draft' | 'published' | 'deprecated'
48
+ features?: string[]
49
+ metadata?: Record<string, unknown>
50
+ createdAt: Date
51
+ publishedAt?: Date
52
+ }
53
+
54
+ interface BundleData {
55
+ id: string
56
+ name: string
57
+ description: string
58
+ productIds: string[]
59
+ pricing?: PricingData
60
+ metadata?: Record<string, unknown>
61
+ createdAt: Date
62
+ updatedAt: Date
63
+ }
64
+
65
+ interface PricingData {
66
+ model: 'free' | 'one-time' | 'subscription' | 'usage-based'
67
+ amount?: number
68
+ currency?: string
69
+ interval?: 'month' | 'year'
70
+ }
71
+
72
+ interface ListOptions {
73
+ limit?: number
74
+ offset?: number
75
+ status?: string
76
+ type?: string
77
+ orderBy?: string
78
+ order?: 'asc' | 'desc'
79
+ }
80
+
81
+ // Expected service interface
82
+ interface ProductServiceCoreInterface {
83
+ // Product CRUD
84
+ create(data: Partial<ProductData>): Promise<ProductData>
85
+ get(id: string): Promise<ProductData | null>
86
+ update(id: string, data: Partial<ProductData>): Promise<ProductData>
87
+ delete(id: string): Promise<boolean>
88
+ list(options?: ListOptions): Promise<ProductData[]>
89
+
90
+ // Feature operations
91
+ addFeature(productId: string, feature: Partial<FeatureData>): Promise<FeatureData>
92
+ updateFeature(featureId: string, data: Partial<FeatureData>): Promise<FeatureData>
93
+ removeFeature(featureId: string): Promise<boolean>
94
+ listFeatures(productId: string): Promise<FeatureData[]>
95
+
96
+ // Version operations
97
+ publish(productId: string, version: string, changelog?: string): Promise<VersionData>
98
+ listVersions(productId: string): Promise<VersionData[]>
99
+ getVersion(productId: string, version: string): Promise<VersionData | null>
100
+ deprecateVersion(productId: string, version: string): Promise<VersionData>
101
+
102
+ // Bundle/composition operations
103
+ compose(name: string, productIds: string[], options?: Partial<BundleData>): Promise<BundleData>
104
+ getBundle(bundleId: string): Promise<BundleData | null>
105
+ updateBundle(bundleId: string, data: Partial<BundleData>): Promise<BundleData>
106
+ listBundles(): Promise<BundleData[]>
107
+ }
108
+
109
+ describe('ProductService (WorkerEntrypoint)', () => {
110
+ describe('class structure', () => {
111
+ it('should extend WorkerEntrypoint', async () => {
112
+ // This import should fail initially - worker.ts doesn't exist
113
+ const { ProductService } = await import('../src/worker.js')
114
+
115
+ // Verify the class exists and extends WorkerEntrypoint
116
+ expect(ProductService).toBeDefined()
117
+ expect(typeof ProductService).toBe('function')
118
+ // WorkerEntrypoint check - should have prototype chain
119
+ expect(ProductService.prototype).toBeDefined()
120
+ })
121
+
122
+ it('should have a connect method', async () => {
123
+ const { ProductService } = await import('../src/worker.js')
124
+ expect(typeof ProductService.prototype.connect).toBe('function')
125
+ })
126
+
127
+ it('should export ProductService as default', async () => {
128
+ const { default: DefaultExport, ProductService } = await import('../src/worker.js')
129
+ expect(DefaultExport).toBe(ProductService)
130
+ })
131
+ })
132
+ })
133
+
134
+ describe('ProductServiceCore (RpcTarget)', () => {
135
+ describe('class structure', () => {
136
+ it('should extend RpcTarget', async () => {
137
+ const { ProductServiceCore } = await import('../src/worker.js')
138
+ expect(ProductServiceCore).toBeDefined()
139
+ expect(typeof ProductServiceCore).toBe('function')
140
+ })
141
+
142
+ it('should accept env in constructor', async () => {
143
+ const { ProductServiceCore } = await import('../src/worker.js')
144
+ const core = new ProductServiceCore(env)
145
+ expect(core).toBeDefined()
146
+ })
147
+ })
148
+ })
149
+
150
+ describe('ProductServiceCore via connect()', () => {
151
+ let service: ProductServiceCoreInterface
152
+
153
+ beforeEach(async () => {
154
+ // Import the worker module
155
+ const { ProductService } = await import('../src/worker.js')
156
+
157
+ // Create a service instance via connect()
158
+ const worker = new ProductService({ env } as any, {} as any)
159
+ service = worker.connect() as ProductServiceCoreInterface
160
+ })
161
+
162
+ describe('Product CRUD Operations', () => {
163
+ describe('create()', () => {
164
+ it('should create a product with auto-generated ID', async () => {
165
+ const product = await service.create({
166
+ name: 'Test Product',
167
+ description: 'A test digital product',
168
+ version: '1.0.0',
169
+ })
170
+
171
+ expect(product).toBeDefined()
172
+ expect(product.id).toBeDefined()
173
+ expect(product.id.length).toBeGreaterThan(0)
174
+ expect(product.name).toBe('Test Product')
175
+ expect(product.description).toBe('A test digital product')
176
+ expect(product.version).toBe('1.0.0')
177
+ expect(product.status).toBe('draft')
178
+ expect(product.createdAt).toBeInstanceOf(Date)
179
+ expect(product.updatedAt).toBeInstanceOf(Date)
180
+ })
181
+
182
+ it('should create a product with custom ID', async () => {
183
+ const product = await service.create({
184
+ id: 'custom-product-id',
185
+ name: 'Custom Product',
186
+ description: 'Product with custom ID',
187
+ version: '1.0.0',
188
+ })
189
+
190
+ expect(product.id).toBe('custom-product-id')
191
+ })
192
+
193
+ it('should create a product with metadata and tags', async () => {
194
+ const product = await service.create({
195
+ name: 'Tagged Product',
196
+ description: 'A product with metadata',
197
+ version: '1.0.0',
198
+ metadata: { category: 'saas', priority: 1 },
199
+ tags: ['enterprise', 'api'],
200
+ })
201
+
202
+ expect(product.metadata).toEqual({ category: 'saas', priority: 1 })
203
+ expect(product.tags).toEqual(['enterprise', 'api'])
204
+ })
205
+
206
+ it('should create a product with specific status', async () => {
207
+ const product = await service.create({
208
+ name: 'Active Product',
209
+ description: 'An active product',
210
+ version: '1.0.0',
211
+ status: 'active',
212
+ })
213
+
214
+ expect(product.status).toBe('active')
215
+ })
216
+ })
217
+
218
+ describe('get()', () => {
219
+ it('should retrieve a created product by ID', async () => {
220
+ const created = await service.create({
221
+ name: 'Retrievable Product',
222
+ description: 'Test retrieval',
223
+ version: '1.0.0',
224
+ })
225
+
226
+ const retrieved = await service.get(created.id)
227
+
228
+ expect(retrieved).not.toBeNull()
229
+ expect(retrieved!.id).toBe(created.id)
230
+ expect(retrieved!.name).toBe('Retrievable Product')
231
+ })
232
+
233
+ it('should return null for non-existent product', async () => {
234
+ const result = await service.get('nonexistent-product-id')
235
+ expect(result).toBeNull()
236
+ })
237
+ })
238
+
239
+ describe('update()', () => {
240
+ it('should update product fields', async () => {
241
+ const created = await service.create({
242
+ name: 'Original Name',
243
+ description: 'Original description',
244
+ version: '1.0.0',
245
+ })
246
+
247
+ const updated = await service.update(created.id, {
248
+ name: 'Updated Name',
249
+ description: 'Updated description',
250
+ })
251
+
252
+ expect(updated.name).toBe('Updated Name')
253
+ expect(updated.description).toBe('Updated description')
254
+ expect(updated.version).toBe('1.0.0') // Unchanged
255
+ expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(created.createdAt.getTime())
256
+ })
257
+
258
+ it('should update product status', async () => {
259
+ const created = await service.create({
260
+ name: 'Draft Product',
261
+ description: 'Starting as draft',
262
+ version: '1.0.0',
263
+ })
264
+
265
+ const updated = await service.update(created.id, { status: 'active' })
266
+
267
+ expect(updated.status).toBe('active')
268
+ })
269
+
270
+ it('should merge metadata on update', async () => {
271
+ const created = await service.create({
272
+ name: 'Product with Metadata',
273
+ description: 'Has metadata',
274
+ version: '1.0.0',
275
+ metadata: { key1: 'value1' },
276
+ })
277
+
278
+ const updated = await service.update(created.id, {
279
+ metadata: { key2: 'value2' },
280
+ })
281
+
282
+ expect(updated.metadata).toEqual({ key1: 'value1', key2: 'value2' })
283
+ })
284
+
285
+ it('should throw error for non-existent product', async () => {
286
+ await expect(service.update('nonexistent-id', { name: 'Test' })).rejects.toThrow()
287
+ })
288
+ })
289
+
290
+ describe('delete()', () => {
291
+ it('should delete a product and return true', async () => {
292
+ const created = await service.create({
293
+ name: 'To Be Deleted',
294
+ description: 'Will be deleted',
295
+ version: '1.0.0',
296
+ })
297
+
298
+ const deleted = await service.delete(created.id)
299
+ expect(deleted).toBe(true)
300
+
301
+ // Verify deletion
302
+ const retrieved = await service.get(created.id)
303
+ expect(retrieved).toBeNull()
304
+ })
305
+
306
+ it('should return false for non-existent product', async () => {
307
+ const deleted = await service.delete('nonexistent-id')
308
+ expect(deleted).toBe(false)
309
+ })
310
+
311
+ it('should cascade delete features when product is deleted', async () => {
312
+ const product = await service.create({
313
+ name: 'Product with Features',
314
+ description: 'Has features to delete',
315
+ version: '1.0.0',
316
+ })
317
+
318
+ await service.addFeature(product.id, {
319
+ name: 'Feature 1',
320
+ description: 'A feature',
321
+ })
322
+
323
+ await service.delete(product.id)
324
+
325
+ const features = await service.listFeatures(product.id)
326
+ expect(features).toEqual([])
327
+ })
328
+ })
329
+
330
+ describe('list()', () => {
331
+ it('should list all products', async () => {
332
+ await service.create({ name: 'Product 1', description: 'First', version: '1.0.0' })
333
+ await service.create({ name: 'Product 2', description: 'Second', version: '1.0.0' })
334
+ await service.create({ name: 'Product 3', description: 'Third', version: '1.0.0' })
335
+
336
+ const products = await service.list()
337
+
338
+ expect(products.length).toBeGreaterThanOrEqual(3)
339
+ })
340
+
341
+ it('should support limit option', async () => {
342
+ await service.create({ name: 'L1', description: 'First', version: '1.0.0' })
343
+ await service.create({ name: 'L2', description: 'Second', version: '1.0.0' })
344
+ await service.create({ name: 'L3', description: 'Third', version: '1.0.0' })
345
+
346
+ const products = await service.list({ limit: 2 })
347
+
348
+ expect(products).toHaveLength(2)
349
+ })
350
+
351
+ it('should support offset option', async () => {
352
+ const p1 = await service.create({ name: 'O1', description: 'First', version: '1.0.0' })
353
+ const p2 = await service.create({ name: 'O2', description: 'Second', version: '1.0.0' })
354
+ const p3 = await service.create({ name: 'O3', description: 'Third', version: '1.0.0' })
355
+
356
+ const products = await service.list({ offset: 1, limit: 2 })
357
+
358
+ expect(products.length).toBeLessThanOrEqual(2)
359
+ })
360
+
361
+ it('should filter by status', async () => {
362
+ await service.create({
363
+ name: 'Draft',
364
+ description: 'Draft',
365
+ version: '1.0.0',
366
+ status: 'draft',
367
+ })
368
+ await service.create({
369
+ name: 'Active',
370
+ description: 'Active',
371
+ version: '1.0.0',
372
+ status: 'active',
373
+ })
374
+ await service.create({
375
+ name: 'Deprecated',
376
+ description: 'Deprecated',
377
+ version: '1.0.0',
378
+ status: 'deprecated',
379
+ })
380
+
381
+ const activeProducts = await service.list({ status: 'active' })
382
+
383
+ expect(activeProducts.every((p) => p.status === 'active')).toBe(true)
384
+ })
385
+
386
+ it('should support ordering', async () => {
387
+ await service.create({ name: 'Alpha', description: 'First', version: '1.0.0' })
388
+ await service.create({ name: 'Beta', description: 'Second', version: '1.0.0' })
389
+ await service.create({ name: 'Gamma', description: 'Third', version: '1.0.0' })
390
+
391
+ const ascending = await service.list({ orderBy: 'name', order: 'asc' })
392
+ const descending = await service.list({ orderBy: 'name', order: 'desc' })
393
+
394
+ // Verify ordering
395
+ expect(ascending[0].name <= ascending[ascending.length - 1].name).toBe(true)
396
+ expect(descending[0].name >= descending[descending.length - 1].name).toBe(true)
397
+ })
398
+ })
399
+ })
400
+
401
+ describe('Feature Operations', () => {
402
+ describe('addFeature()', () => {
403
+ it('should add a feature to a product', async () => {
404
+ const product = await service.create({
405
+ name: 'Product with Features',
406
+ description: 'Has features',
407
+ version: '1.0.0',
408
+ })
409
+
410
+ const feature = await service.addFeature(product.id, {
411
+ name: 'Dark Mode',
412
+ description: 'Toggle dark theme',
413
+ })
414
+
415
+ expect(feature).toBeDefined()
416
+ expect(feature.id).toBeDefined()
417
+ expect(feature.name).toBe('Dark Mode')
418
+ expect(feature.description).toBe('Toggle dark theme')
419
+ expect(feature.productId).toBe(product.id)
420
+ expect(feature.status).toBe('draft')
421
+ expect(feature.createdAt).toBeInstanceOf(Date)
422
+ })
423
+
424
+ it('should add a feature with specific status', async () => {
425
+ const product = await service.create({
426
+ name: 'Product',
427
+ description: 'Has beta feature',
428
+ version: '1.0.0',
429
+ })
430
+
431
+ const feature = await service.addFeature(product.id, {
432
+ name: 'Beta Feature',
433
+ description: 'In beta',
434
+ status: 'beta',
435
+ })
436
+
437
+ expect(feature.status).toBe('beta')
438
+ })
439
+
440
+ it('should throw error for non-existent product', async () => {
441
+ await expect(
442
+ service.addFeature('nonexistent-product', {
443
+ name: 'Feature',
444
+ description: 'Test',
445
+ })
446
+ ).rejects.toThrow()
447
+ })
448
+ })
449
+
450
+ describe('updateFeature()', () => {
451
+ it('should update feature fields', async () => {
452
+ const product = await service.create({
453
+ name: 'Product',
454
+ description: 'Test',
455
+ version: '1.0.0',
456
+ })
457
+
458
+ const feature = await service.addFeature(product.id, {
459
+ name: 'Original Feature',
460
+ description: 'Original description',
461
+ })
462
+
463
+ const updated = await service.updateFeature(feature.id, {
464
+ name: 'Updated Feature',
465
+ status: 'ga',
466
+ })
467
+
468
+ expect(updated.name).toBe('Updated Feature')
469
+ expect(updated.status).toBe('ga')
470
+ })
471
+
472
+ it('should throw error for non-existent feature', async () => {
473
+ await expect(
474
+ service.updateFeature('nonexistent-feature', { name: 'Test' })
475
+ ).rejects.toThrow()
476
+ })
477
+ })
478
+
479
+ describe('removeFeature()', () => {
480
+ it('should remove a feature', async () => {
481
+ const product = await service.create({
482
+ name: 'Product',
483
+ description: 'Test',
484
+ version: '1.0.0',
485
+ })
486
+
487
+ const feature = await service.addFeature(product.id, {
488
+ name: 'To Remove',
489
+ description: 'Will be removed',
490
+ })
491
+
492
+ const removed = await service.removeFeature(feature.id)
493
+ expect(removed).toBe(true)
494
+
495
+ const features = await service.listFeatures(product.id)
496
+ expect(features.find((f) => f.id === feature.id)).toBeUndefined()
497
+ })
498
+
499
+ it('should return false for non-existent feature', async () => {
500
+ const removed = await service.removeFeature('nonexistent-feature')
501
+ expect(removed).toBe(false)
502
+ })
503
+ })
504
+
505
+ describe('listFeatures()', () => {
506
+ it('should list all features for a product', async () => {
507
+ const product = await service.create({
508
+ name: 'Multi-Feature Product',
509
+ description: 'Has many features',
510
+ version: '1.0.0',
511
+ })
512
+
513
+ await service.addFeature(product.id, { name: 'Feature 1', description: 'First' })
514
+ await service.addFeature(product.id, { name: 'Feature 2', description: 'Second' })
515
+ await service.addFeature(product.id, { name: 'Feature 3', description: 'Third' })
516
+
517
+ const features = await service.listFeatures(product.id)
518
+
519
+ expect(features).toHaveLength(3)
520
+ expect(features.every((f) => f.productId === product.id)).toBe(true)
521
+ })
522
+
523
+ it('should return empty array for product with no features', async () => {
524
+ const product = await service.create({
525
+ name: 'Empty Product',
526
+ description: 'No features',
527
+ version: '1.0.0',
528
+ })
529
+
530
+ const features = await service.listFeatures(product.id)
531
+ expect(features).toEqual([])
532
+ })
533
+ })
534
+ })
535
+
536
+ describe('Version Operations', () => {
537
+ describe('publish()', () => {
538
+ it('should publish a new version', async () => {
539
+ const product = await service.create({
540
+ name: 'Versionable Product',
541
+ description: 'Can be versioned',
542
+ version: '1.0.0',
543
+ })
544
+
545
+ const version = await service.publish(product.id, '1.1.0', 'Added new features')
546
+
547
+ expect(version).toBeDefined()
548
+ expect(version.id).toBeDefined()
549
+ expect(version.productId).toBe(product.id)
550
+ expect(version.version).toBe('1.1.0')
551
+ expect(version.changelog).toBe('Added new features')
552
+ expect(version.status).toBe('published')
553
+ expect(version.createdAt).toBeInstanceOf(Date)
554
+ expect(version.publishedAt).toBeInstanceOf(Date)
555
+ })
556
+
557
+ it('should publish version without changelog', async () => {
558
+ const product = await service.create({
559
+ name: 'Product',
560
+ description: 'Test',
561
+ version: '1.0.0',
562
+ })
563
+
564
+ const version = await service.publish(product.id, '1.0.1')
565
+
566
+ expect(version.version).toBe('1.0.1')
567
+ expect(version.changelog).toBeUndefined()
568
+ })
569
+
570
+ it('should throw error for non-existent product', async () => {
571
+ await expect(service.publish('nonexistent-product', '1.0.0')).rejects.toThrow()
572
+ })
573
+
574
+ it('should throw error for duplicate version', async () => {
575
+ const product = await service.create({
576
+ name: 'Product',
577
+ description: 'Test',
578
+ version: '1.0.0',
579
+ })
580
+
581
+ await service.publish(product.id, '1.1.0')
582
+
583
+ await expect(service.publish(product.id, '1.1.0')).rejects.toThrow()
584
+ })
585
+ })
586
+
587
+ describe('listVersions()', () => {
588
+ it('should list all versions for a product', async () => {
589
+ const product = await service.create({
590
+ name: 'Multi-Version Product',
591
+ description: 'Has many versions',
592
+ version: '1.0.0',
593
+ })
594
+
595
+ await service.publish(product.id, '1.0.0', 'Initial release')
596
+ await service.publish(product.id, '1.1.0', 'Minor update')
597
+ await service.publish(product.id, '2.0.0', 'Major update')
598
+
599
+ const versions = await service.listVersions(product.id)
600
+
601
+ expect(versions).toHaveLength(3)
602
+ expect(versions.every((v) => v.productId === product.id)).toBe(true)
603
+ })
604
+
605
+ it('should return versions in order', async () => {
606
+ const product = await service.create({
607
+ name: 'Product',
608
+ description: 'Test',
609
+ version: '1.0.0',
610
+ })
611
+
612
+ await service.publish(product.id, '1.0.0')
613
+ await service.publish(product.id, '1.1.0')
614
+ await service.publish(product.id, '1.2.0')
615
+
616
+ const versions = await service.listVersions(product.id)
617
+
618
+ // Versions should be ordered (typically newest first or by version)
619
+ expect(versions.length).toBe(3)
620
+ })
621
+ })
622
+
623
+ describe('getVersion()', () => {
624
+ it('should get a specific version', async () => {
625
+ const product = await service.create({
626
+ name: 'Product',
627
+ description: 'Test',
628
+ version: '1.0.0',
629
+ })
630
+
631
+ await service.publish(product.id, '1.0.0', 'Initial')
632
+ await service.publish(product.id, '1.1.0', 'Update')
633
+
634
+ const version = await service.getVersion(product.id, '1.0.0')
635
+
636
+ expect(version).not.toBeNull()
637
+ expect(version!.version).toBe('1.0.0')
638
+ expect(version!.changelog).toBe('Initial')
639
+ })
640
+
641
+ it('should return null for non-existent version', async () => {
642
+ const product = await service.create({
643
+ name: 'Product',
644
+ description: 'Test',
645
+ version: '1.0.0',
646
+ })
647
+
648
+ const version = await service.getVersion(product.id, '9.9.9')
649
+ expect(version).toBeNull()
650
+ })
651
+ })
652
+
653
+ describe('deprecateVersion()', () => {
654
+ it('should deprecate a version', async () => {
655
+ const product = await service.create({
656
+ name: 'Product',
657
+ description: 'Test',
658
+ version: '1.0.0',
659
+ })
660
+
661
+ await service.publish(product.id, '1.0.0')
662
+ await service.publish(product.id, '2.0.0')
663
+
664
+ const deprecated = await service.deprecateVersion(product.id, '1.0.0')
665
+
666
+ expect(deprecated.status).toBe('deprecated')
667
+ })
668
+
669
+ it('should throw error for non-existent version', async () => {
670
+ const product = await service.create({
671
+ name: 'Product',
672
+ description: 'Test',
673
+ version: '1.0.0',
674
+ })
675
+
676
+ await expect(service.deprecateVersion(product.id, '9.9.9')).rejects.toThrow()
677
+ })
678
+ })
679
+ })
680
+
681
+ describe('Bundle/Composition Operations', () => {
682
+ describe('compose()', () => {
683
+ it('should create a bundle from multiple products', async () => {
684
+ const product1 = await service.create({
685
+ name: 'Product A',
686
+ description: 'First product',
687
+ version: '1.0.0',
688
+ })
689
+
690
+ const product2 = await service.create({
691
+ name: 'Product B',
692
+ description: 'Second product',
693
+ version: '1.0.0',
694
+ })
695
+
696
+ const bundle = await service.compose('Product Bundle', [product1.id, product2.id], {
697
+ description: 'A bundle of products',
698
+ })
699
+
700
+ expect(bundle).toBeDefined()
701
+ expect(bundle.id).toBeDefined()
702
+ expect(bundle.name).toBe('Product Bundle')
703
+ expect(bundle.description).toBe('A bundle of products')
704
+ expect(bundle.productIds).toContain(product1.id)
705
+ expect(bundle.productIds).toContain(product2.id)
706
+ expect(bundle.createdAt).toBeInstanceOf(Date)
707
+ })
708
+
709
+ it('should create a bundle with pricing', async () => {
710
+ const product1 = await service.create({
711
+ name: 'Product 1',
712
+ description: 'Test',
713
+ version: '1.0.0',
714
+ })
715
+
716
+ const product2 = await service.create({
717
+ name: 'Product 2',
718
+ description: 'Test',
719
+ version: '1.0.0',
720
+ })
721
+
722
+ const bundle = await service.compose('Premium Bundle', [product1.id, product2.id], {
723
+ description: 'Premium bundle with pricing',
724
+ pricing: {
725
+ model: 'subscription',
726
+ amount: 99,
727
+ currency: 'USD',
728
+ interval: 'month',
729
+ },
730
+ })
731
+
732
+ expect(bundle.pricing).toBeDefined()
733
+ expect(bundle.pricing!.model).toBe('subscription')
734
+ expect(bundle.pricing!.amount).toBe(99)
735
+ expect(bundle.pricing!.currency).toBe('USD')
736
+ expect(bundle.pricing!.interval).toBe('month')
737
+ })
738
+
739
+ it('should throw error for empty product list', async () => {
740
+ await expect(service.compose('Empty Bundle', [])).rejects.toThrow()
741
+ })
742
+
743
+ it('should throw error for non-existent products', async () => {
744
+ await expect(
745
+ service.compose('Invalid Bundle', ['nonexistent-1', 'nonexistent-2'])
746
+ ).rejects.toThrow()
747
+ })
748
+ })
749
+
750
+ describe('getBundle()', () => {
751
+ it('should retrieve a bundle by ID', async () => {
752
+ const product = await service.create({
753
+ name: 'Product',
754
+ description: 'Test',
755
+ version: '1.0.0',
756
+ })
757
+
758
+ const created = await service.compose('Test Bundle', [product.id])
759
+ const retrieved = await service.getBundle(created.id)
760
+
761
+ expect(retrieved).not.toBeNull()
762
+ expect(retrieved!.id).toBe(created.id)
763
+ expect(retrieved!.name).toBe('Test Bundle')
764
+ })
765
+
766
+ it('should return null for non-existent bundle', async () => {
767
+ const result = await service.getBundle('nonexistent-bundle')
768
+ expect(result).toBeNull()
769
+ })
770
+ })
771
+
772
+ describe('updateBundle()', () => {
773
+ it('should update bundle fields', async () => {
774
+ const product = await service.create({
775
+ name: 'Product',
776
+ description: 'Test',
777
+ version: '1.0.0',
778
+ })
779
+
780
+ const bundle = await service.compose('Original Bundle', [product.id])
781
+
782
+ const updated = await service.updateBundle(bundle.id, {
783
+ name: 'Updated Bundle',
784
+ description: 'Updated description',
785
+ })
786
+
787
+ expect(updated.name).toBe('Updated Bundle')
788
+ expect(updated.description).toBe('Updated description')
789
+ })
790
+
791
+ it('should update bundle product list', async () => {
792
+ const product1 = await service.create({ name: 'P1', description: 'Test', version: '1.0.0' })
793
+ const product2 = await service.create({ name: 'P2', description: 'Test', version: '1.0.0' })
794
+ const product3 = await service.create({ name: 'P3', description: 'Test', version: '1.0.0' })
795
+
796
+ const bundle = await service.compose('Bundle', [product1.id, product2.id])
797
+
798
+ const updated = await service.updateBundle(bundle.id, {
799
+ productIds: [product1.id, product2.id, product3.id],
800
+ })
801
+
802
+ expect(updated.productIds).toHaveLength(3)
803
+ expect(updated.productIds).toContain(product3.id)
804
+ })
805
+
806
+ it('should throw error for non-existent bundle', async () => {
807
+ await expect(service.updateBundle('nonexistent-bundle', { name: 'Test' })).rejects.toThrow()
808
+ })
809
+ })
810
+
811
+ describe('listBundles()', () => {
812
+ it('should list all bundles', async () => {
813
+ const product = await service.create({
814
+ name: 'Product',
815
+ description: 'Test',
816
+ version: '1.0.0',
817
+ })
818
+
819
+ await service.compose('Bundle 1', [product.id])
820
+ await service.compose('Bundle 2', [product.id])
821
+ await service.compose('Bundle 3', [product.id])
822
+
823
+ const bundles = await service.listBundles()
824
+
825
+ expect(bundles.length).toBeGreaterThanOrEqual(3)
826
+ })
827
+ })
828
+ })
829
+ })
830
+
831
+ describe('ProductServiceCore integration with AI', () => {
832
+ let service: ProductServiceCoreInterface
833
+
834
+ beforeEach(async () => {
835
+ const { ProductService } = await import('../src/worker.js')
836
+ const worker = new ProductService({ env } as any, {} as any)
837
+ service = worker.connect() as ProductServiceCoreInterface
838
+ })
839
+
840
+ it('should have AI binding available for AI-assisted operations', () => {
841
+ // env.AI should be available in workers environment for potential AI features
842
+ expect(env).toBeDefined()
843
+ })
844
+
845
+ it('should use PRODUCT_CATALOG Durable Object for persistence', async () => {
846
+ // Create and retrieve to verify DO persistence
847
+ const product = await service.create({
848
+ name: 'Persistent Product',
849
+ description: 'Should persist in DO',
850
+ version: '1.0.0',
851
+ })
852
+
853
+ const retrieved = await service.get(product.id)
854
+ expect(retrieved).not.toBeNull()
855
+ expect(retrieved!.name).toBe('Persistent Product')
856
+ })
857
+ })
858
+
859
+ describe('Data Persistence', () => {
860
+ it('should persist data across service calls', async () => {
861
+ const { ProductService } = await import('../src/worker.js')
862
+
863
+ // First connection - create data
864
+ const worker1 = new ProductService({ env } as any, {} as any)
865
+ const service1 = worker1.connect() as ProductServiceCoreInterface
866
+ const created = await service1.create({
867
+ name: 'Persistent Product',
868
+ description: 'Should persist',
869
+ version: '1.0.0',
870
+ })
871
+ const createdId = created.id
872
+
873
+ // Second connection - verify data persists
874
+ const worker2 = new ProductService({ env } as any, {} as any)
875
+ const service2 = worker2.connect() as ProductServiceCoreInterface
876
+ const retrieved = await service2.get(createdId)
877
+
878
+ expect(retrieved).not.toBeNull()
879
+ expect(retrieved!.name).toBe('Persistent Product')
880
+ })
881
+ })
882
+
883
+ describe('Error Handling', () => {
884
+ let service: ProductServiceCoreInterface
885
+
886
+ beforeEach(async () => {
887
+ const { ProductService } = await import('../src/worker.js')
888
+ const worker = new ProductService({ env } as any, {} as any)
889
+ service = worker.connect() as ProductServiceCoreInterface
890
+ })
891
+
892
+ it('should handle invalid product data gracefully', async () => {
893
+ // Missing required fields should throw
894
+ await expect(service.create({} as any)).rejects.toThrow()
895
+ })
896
+
897
+ it('should handle concurrent operations', async () => {
898
+ // Create multiple products concurrently
899
+ const promises = Array.from({ length: 10 }, (_, i) =>
900
+ service.create({
901
+ name: `Concurrent Product ${i}`,
902
+ description: 'Created concurrently',
903
+ version: '1.0.0',
904
+ })
905
+ )
906
+
907
+ const products = await Promise.all(promises)
908
+
909
+ expect(products).toHaveLength(10)
910
+ expect(new Set(products.map((p) => p.id)).size).toBe(10) // All unique IDs
911
+ })
912
+ })