UNPKG

business-as-code

Version:

Primitives for expressing business logic and processes as code

431 lines (360 loc) 13.9 kB
/** * Tests for product.ts - Product definition and management */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { Product, calculateGrossMargin, calculateGrossProfit, getRoadmapByStatus, getRoadmapByPriority, getOverdueRoadmapItems, updateRoadmapItem, addFeature, removeFeature, validateProduct, } from '../src/product.js' import type { ProductDefinition, RoadmapItem } from '../src/types.js' describe('Product', () => { describe('Product()', () => { it('should create a product with required fields', () => { const product = Product({ name: 'Test Product' }) expect(product.name).toBe('Test Product') expect(product.pricingModel).toBe('one-time') expect(product.currency).toBe('USD') expect(product.features).toEqual([]) expect(product.roadmap).toEqual([]) expect(product.metadata).toEqual({}) }) it('should create a product with all fields', () => { const product = Product({ name: 'Widget Pro', description: 'Enterprise widget platform', category: 'SaaS', targetSegment: 'Enterprise', valueProposition: 'Reduce costs by 50%', pricingModel: 'subscription', price: 99, currency: 'EUR', cogs: 20, features: ['Feature 1', 'Feature 2'], roadmap: [{ name: 'Mobile App', status: 'planned', priority: 'high' }], metadata: { tier: 'premium' }, }) expect(product.name).toBe('Widget Pro') expect(product.description).toBe('Enterprise widget platform') expect(product.category).toBe('SaaS') expect(product.pricingModel).toBe('subscription') expect(product.price).toBe(99) expect(product.currency).toBe('EUR') expect(product.cogs).toBe(20) expect(product.features).toHaveLength(2) expect(product.roadmap).toHaveLength(1) expect(product.metadata).toEqual({ tier: 'premium' }) }) it('should throw error if name is missing', () => { expect(() => Product({ name: '' })).toThrow('Product name is required') }) it('should preserve provided values and not override with defaults', () => { const product = Product({ name: 'Test', pricingModel: 'usage-based', currency: 'GBP', features: ['Custom Feature'], }) expect(product.pricingModel).toBe('usage-based') expect(product.currency).toBe('GBP') expect(product.features).toEqual(['Custom Feature']) }) }) describe('calculateGrossMargin()', () => { it('should calculate gross margin percentage', () => { const product = Product({ name: 'Test', price: 100, cogs: 30 }) expect(calculateGrossMargin(product)).toBe(70) }) it('should return 0 if price is missing', () => { const product = Product({ name: 'Test', cogs: 30 }) expect(calculateGrossMargin(product)).toBe(0) }) it('should return 0 if cogs is missing', () => { const product = Product({ name: 'Test', price: 100 }) expect(calculateGrossMargin(product)).toBe(0) }) it('should handle zero cogs (returns 0 since cogs is falsy)', () => { // The implementation treats cogs: 0 as missing (falsy check) const product = Product({ name: 'Test', price: 100, cogs: 0 }) expect(calculateGrossMargin(product)).toBe(0) }) it('should handle high cogs (low margin)', () => { const product = Product({ name: 'Test', price: 100, cogs: 90 }) expect(calculateGrossMargin(product)).toBe(10) }) }) describe('calculateGrossProfit()', () => { it('should calculate gross profit for units sold', () => { const product = Product({ name: 'Test', price: 100, cogs: 30 }) expect(calculateGrossProfit(product, 10)).toBe(700) }) it('should return 0 if price is missing', () => { const product = Product({ name: 'Test', cogs: 30 }) expect(calculateGrossProfit(product, 10)).toBe(0) }) it('should return 0 if cogs is missing', () => { const product = Product({ name: 'Test', price: 100 }) expect(calculateGrossProfit(product, 10)).toBe(0) }) it('should handle zero units sold', () => { const product = Product({ name: 'Test', price: 100, cogs: 30 }) expect(calculateGrossProfit(product, 0)).toBe(0) }) it('should handle large volume', () => { const product = Product({ name: 'Test', price: 50, cogs: 20 }) expect(calculateGrossProfit(product, 1000)).toBe(30000) }) }) describe('getRoadmapByStatus()', () => { const product = Product({ name: 'Test', roadmap: [ { name: 'Feature A', status: 'planned' }, { name: 'Feature B', status: 'in-progress' }, { name: 'Feature C', status: 'planned' }, { name: 'Feature D', status: 'completed' }, ], }) it('should filter roadmap items by status', () => { const planned = getRoadmapByStatus(product, 'planned') expect(planned).toHaveLength(2) expect(planned[0]?.name).toBe('Feature A') expect(planned[1]?.name).toBe('Feature C') }) it('should return empty array for non-existent status', () => { const cancelled = getRoadmapByStatus(product, 'cancelled') expect(cancelled).toHaveLength(0) }) it('should handle product without roadmap', () => { const emptyProduct = Product({ name: 'Empty' }) const result = getRoadmapByStatus(emptyProduct, 'planned') expect(result).toEqual([]) }) }) describe('getRoadmapByPriority()', () => { const product = Product({ name: 'Test', roadmap: [ { name: 'Feature A', priority: 'high' }, { name: 'Feature B', priority: 'low' }, { name: 'Feature C', priority: 'high' }, { name: 'Feature D', priority: 'critical' }, ], }) it('should filter roadmap items by priority', () => { const high = getRoadmapByPriority(product, 'high') expect(high).toHaveLength(2) expect(high[0]?.name).toBe('Feature A') expect(high[1]?.name).toBe('Feature C') }) it('should return empty array for non-existent priority', () => { const medium = getRoadmapByPriority(product, 'medium') expect(medium).toHaveLength(0) }) it('should handle product without roadmap', () => { const emptyProduct = Product({ name: 'Empty' }) const result = getRoadmapByPriority(emptyProduct, 'high') expect(result).toEqual([]) }) }) describe('getOverdueRoadmapItems()', () => { beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(new Date('2024-06-15')) }) afterEach(() => { vi.useRealTimers() }) it('should return overdue roadmap items', () => { const product = Product({ name: 'Test', roadmap: [ { name: 'Feature A', targetDate: new Date('2024-06-01'), status: 'in-progress' }, { name: 'Feature B', targetDate: new Date('2024-12-01'), status: 'planned' }, { name: 'Feature C', targetDate: new Date('2024-05-01'), status: 'planned' }, ], }) const overdue = getOverdueRoadmapItems(product) expect(overdue).toHaveLength(2) expect(overdue.map((i) => i.name)).toContain('Feature A') expect(overdue.map((i) => i.name)).toContain('Feature C') }) it('should not include completed items', () => { const product = Product({ name: 'Test', roadmap: [ { name: 'Feature A', targetDate: new Date('2024-06-01'), status: 'completed' }, { name: 'Feature B', targetDate: new Date('2024-05-01'), status: 'in-progress' }, ], }) const overdue = getOverdueRoadmapItems(product) expect(overdue).toHaveLength(1) expect(overdue[0]?.name).toBe('Feature B') }) it('should not include cancelled items', () => { const product = Product({ name: 'Test', roadmap: [{ name: 'Feature A', targetDate: new Date('2024-06-01'), status: 'cancelled' }], }) const overdue = getOverdueRoadmapItems(product) expect(overdue).toHaveLength(0) }) it('should handle items without target date', () => { const product = Product({ name: 'Test', roadmap: [{ name: 'Feature A', status: 'in-progress' }], }) const overdue = getOverdueRoadmapItems(product) expect(overdue).toHaveLength(0) }) }) describe('updateRoadmapItem()', () => { it('should update roadmap item properties', () => { const product = Product({ name: 'Test', roadmap: [ { name: 'Feature A', status: 'planned', priority: 'low' }, { name: 'Feature B', status: 'planned', priority: 'medium' }, ], }) const updated = updateRoadmapItem(product, 'Feature A', { status: 'in-progress', priority: 'high', }) const item = updated.roadmap?.find((i) => i.name === 'Feature A') expect(item?.status).toBe('in-progress') expect(item?.priority).toBe('high') }) it('should not affect other roadmap items', () => { const product = Product({ name: 'Test', roadmap: [ { name: 'Feature A', status: 'planned' }, { name: 'Feature B', status: 'planned' }, ], }) const updated = updateRoadmapItem(product, 'Feature A', { status: 'completed' }) const itemB = updated.roadmap?.find((i) => i.name === 'Feature B') expect(itemB?.status).toBe('planned') }) it('should handle non-existent item', () => { const product = Product({ name: 'Test', roadmap: [{ name: 'Feature A', status: 'planned' }], }) const updated = updateRoadmapItem(product, 'NonExistent', { status: 'completed' }) expect(updated.roadmap).toHaveLength(1) expect(updated.roadmap?.[0]?.status).toBe('planned') }) }) describe('addFeature()', () => { it('should add feature to product', () => { const product = Product({ name: 'Test', features: ['Feature 1'], }) const updated = addFeature(product, 'Feature 2') expect(updated.features).toHaveLength(2) expect(updated.features).toContain('Feature 2') }) it('should add feature to product without features', () => { const product = Product({ name: 'Test' }) const updated = addFeature(product, 'New Feature') expect(updated.features).toHaveLength(1) expect(updated.features).toContain('New Feature') }) it('should preserve existing features', () => { const product = Product({ name: 'Test', features: ['Feature 1', 'Feature 2'], }) const updated = addFeature(product, 'Feature 3') expect(updated.features).toContain('Feature 1') expect(updated.features).toContain('Feature 2') expect(updated.features).toContain('Feature 3') }) }) describe('removeFeature()', () => { it('should remove feature from product', () => { const product = Product({ name: 'Test', features: ['Feature 1', 'Feature 2', 'Feature 3'], }) const updated = removeFeature(product, 'Feature 2') expect(updated.features).toHaveLength(2) expect(updated.features).not.toContain('Feature 2') }) it('should handle removing non-existent feature', () => { const product = Product({ name: 'Test', features: ['Feature 1'], }) const updated = removeFeature(product, 'NonExistent') expect(updated.features).toHaveLength(1) expect(updated.features).toContain('Feature 1') }) it('should handle product without features', () => { const product = Product({ name: 'Test' }) const updated = removeFeature(product, 'Feature') expect(updated.features).toEqual([]) }) }) describe('validateProduct()', () => { it('should validate valid product', () => { const product: ProductDefinition = { name: 'Valid Product', price: 100, cogs: 30, } const result = validateProduct(product) expect(result.valid).toBe(true) expect(result.errors).toHaveLength(0) }) it('should fail if name is missing', () => { const product: ProductDefinition = { name: '' } const result = validateProduct(product) expect(result.valid).toBe(false) expect(result.errors).toContain('Product name is required') }) it('should fail if price is negative', () => { const product: ProductDefinition = { name: 'Test', price: -100 } const result = validateProduct(product) expect(result.valid).toBe(false) expect(result.errors).toContain('Product price cannot be negative') }) it('should fail if cogs is negative', () => { const product: ProductDefinition = { name: 'Test', cogs: -30 } const result = validateProduct(product) expect(result.valid).toBe(false) expect(result.errors).toContain('Product COGS cannot be negative') }) it('should fail if cogs exceeds price', () => { const product: ProductDefinition = { name: 'Test', price: 50, cogs: 100 } const result = validateProduct(product) expect(result.valid).toBe(false) expect(result.errors).toContain('Product COGS cannot exceed price') }) it('should allow zero price', () => { const product: ProductDefinition = { name: 'Free Product', price: 0 } const result = validateProduct(product) expect(result.valid).toBe(true) }) it('should return multiple errors', () => { const product: ProductDefinition = { name: '', price: -100, cogs: -50, } const result = validateProduct(product) expect(result.valid).toBe(false) expect(result.errors.length).toBeGreaterThan(1) }) }) })