business-as-code
Version:
Primitives for expressing business logic and processes as code
431 lines (360 loc) • 13.9 kB
text/typescript
/**
* 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)
})
})
})