digital-products 2.1.1 → 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.
- package/CHANGELOG.md +17 -0
- package/README.md +2 -0
- package/dist/api.js +7 -7
- package/dist/api.js.map +1 -1
- package/dist/app.js +6 -6
- package/dist/app.js.map +1 -1
- package/dist/client.d.ts +157 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +69 -0
- package/dist/client.js.map +1 -0
- package/dist/content.js +7 -7
- package/dist/content.js.map +1 -1
- package/dist/data.d.ts.map +1 -1
- package/dist/data.js +6 -6
- package/dist/data.js.map +1 -1
- package/dist/dataset.js +5 -5
- package/dist/dataset.js.map +1 -1
- package/dist/index.d.ts +92 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +139 -15
- package/dist/index.js.map +1 -1
- package/dist/mcp.d.ts +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +17 -10
- package/dist/mcp.js.map +1 -1
- package/dist/product.js +2 -2
- package/dist/product.js.map +1 -1
- package/dist/sdk.d.ts.map +1 -1
- package/dist/sdk.js +52 -16
- package/dist/sdk.js.map +1 -1
- package/dist/site.d.ts.map +1 -1
- package/dist/site.js +12 -8
- package/dist/site.js.map +1 -1
- package/dist/types.d.ts +830 -12
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +495 -2
- package/dist/types.js.map +1 -1
- package/dist/worker.d.ts +205 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +356 -0
- package/dist/worker.js.map +1 -0
- package/package.json +20 -4
- package/src/api.ts +7 -7
- package/src/app.ts +6 -6
- package/src/client.ts +192 -0
- package/src/content.ts +7 -7
- package/src/data.ts +12 -7
- package/src/dataset.ts +5 -5
- package/src/index.ts +151 -15
- package/src/mcp.ts +18 -11
- package/src/product.ts +2 -2
- package/src/sdk.ts +54 -15
- package/src/site.ts +12 -8
- package/src/types.ts +821 -12
- package/src/worker.ts +525 -0
- package/test/product.test.ts +53 -198
- package/test/unified-types.test.ts +589 -0
- package/test/worker.test.ts +912 -0
- package/vitest.config.ts +42 -0
- package/wrangler.jsonc +36 -0
- package/.turbo/turbo-build.log +0 -5
- package/src/api.js +0 -128
- package/src/app.js +0 -106
- package/src/content.js +0 -77
- package/src/data.js +0 -106
- package/src/dataset.js +0 -49
- package/src/entities/ai.js +0 -858
- package/src/entities/content.js +0 -783
- package/src/entities/index.js +0 -88
- package/src/entities/interfaces.js +0 -929
- package/src/entities/lifecycle.js +0 -803
- package/src/entities/products.js +0 -797
- package/src/entities/web.js +0 -657
- package/src/index.js +0 -35
- package/src/mcp.js +0 -139
- package/src/product.js +0 -53
- package/src/registry.js +0 -31
- package/src/sdk.js +0 -127
- package/src/site.js +0 -112
- package/src/types.js +0 -4
|
@@ -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
|
+
})
|