- Full inventory UI: product list with search/filter, product detail with tabs (details, units, suppliers, stock receipts, price history) - Product filters: category, type (serialized/rental/repair), low stock, active/inactive — all server-side with URL-synced state - Product-supplier junction: link products to multiple suppliers with preferred flag, joined supplier details in UI - Stock receipts: record incoming stock with supplier, qty, cost per unit, invoice number; auto-increments qty_on_hand for non-serialized products - Price history tab on product detail page - categories/all endpoint to avoid pagination limit on dropdown fetches - categoryId filter on product list endpoint - Repair parts and additional inventory items in music store seed data - isDualUseRepair corrected: instruments set to false, strings/parts true - Product-supplier links and stock receipts in seed data - Price history seed data simulating cost increases over past year - 37 API tests covering categories, suppliers, products, units, product-suppliers, and stock receipts - alert-dialog and checkbox UI components - sync-and-deploy.sh script for rsync + remote deploy
365 lines
18 KiB
TypeScript
365 lines
18 KiB
TypeScript
import { suite } from '../lib/context.js'
|
|
|
|
suite('Inventory', { tags: ['inventory'] }, (t) => {
|
|
// ─── Categories ────────────────────────────────────────────────────────────
|
|
|
|
t.test('creates a category', { tags: ['categories', 'create'] }, async () => {
|
|
const res = await t.api.post('/v1/categories', { name: 'Violins', sortOrder: 1 })
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.name, 'Violins')
|
|
t.assert.equal(res.data.sortOrder, 1)
|
|
t.assert.equal(res.data.isActive, true)
|
|
t.assert.ok(res.data.id)
|
|
})
|
|
|
|
t.test('rejects category without name', { tags: ['categories', 'create', 'validation'] }, async () => {
|
|
const res = await t.api.post('/v1/categories', {})
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
t.test('lists categories with pagination', { tags: ['categories', 'list'] }, async () => {
|
|
await t.api.post('/v1/categories', { name: 'Cat List A' })
|
|
await t.api.post('/v1/categories', { name: 'Cat List B' })
|
|
const res = await t.api.get('/v1/categories', { page: 1, limit: 25 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.length >= 2)
|
|
t.assert.ok(res.data.pagination)
|
|
})
|
|
|
|
t.test('gets category by id', { tags: ['categories', 'read'] }, async () => {
|
|
const created = await t.api.post('/v1/categories', { name: 'Get By ID Cat' })
|
|
const res = await t.api.get(`/v1/categories/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.name, 'Get By ID Cat')
|
|
})
|
|
|
|
t.test('returns 404 for missing category', { tags: ['categories', 'read'] }, async () => {
|
|
const res = await t.api.get('/v1/categories/a0000000-0000-0000-0000-999999999999')
|
|
t.assert.status(res, 404)
|
|
})
|
|
|
|
t.test('updates a category', { tags: ['categories', 'update'] }, async () => {
|
|
const created = await t.api.post('/v1/categories', { name: 'Before Update' })
|
|
const res = await t.api.patch(`/v1/categories/${created.data.id}`, { name: 'After Update', sortOrder: 5 })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.name, 'After Update')
|
|
t.assert.equal(res.data.sortOrder, 5)
|
|
})
|
|
|
|
t.test('soft-deletes a category', { tags: ['categories', 'delete'] }, async () => {
|
|
const created = await t.api.post('/v1/categories', { name: 'To Delete Cat' })
|
|
const res = await t.api.del(`/v1/categories/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.isActive, false)
|
|
})
|
|
|
|
// ─── Suppliers ─────────────────────────────────────────────────────────────
|
|
|
|
t.test('creates a supplier', { tags: ['suppliers', 'create'] }, async () => {
|
|
const res = await t.api.post('/v1/suppliers', {
|
|
name: 'Shar Music',
|
|
email: 'orders@sharmusic.com',
|
|
phone: '800-248-7427',
|
|
paymentTerms: 'Net 30',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.name, 'Shar Music')
|
|
t.assert.equal(res.data.paymentTerms, 'Net 30')
|
|
t.assert.ok(res.data.id)
|
|
})
|
|
|
|
t.test('rejects supplier without name', { tags: ['suppliers', 'create', 'validation'] }, async () => {
|
|
const res = await t.api.post('/v1/suppliers', { email: 'x@x.com' })
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
t.test('lists suppliers with pagination', { tags: ['suppliers', 'list'] }, async () => {
|
|
await t.api.post('/v1/suppliers', { name: 'Sup List A' })
|
|
await t.api.post('/v1/suppliers', { name: 'Sup List B' })
|
|
const res = await t.api.get('/v1/suppliers', { page: 1, limit: 25 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.length >= 2)
|
|
t.assert.ok(res.data.pagination.total >= 2)
|
|
})
|
|
|
|
t.test('searches suppliers by name', { tags: ['suppliers', 'list', 'search'] }, async () => {
|
|
await t.api.post('/v1/suppliers', { name: 'SearchableSupplierXYZ' })
|
|
const res = await t.api.get('/v1/suppliers', { q: 'SearchableSupplierXYZ' })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 1)
|
|
t.assert.equal(res.data.data[0].name, 'SearchableSupplierXYZ')
|
|
})
|
|
|
|
t.test('updates a supplier', { tags: ['suppliers', 'update'] }, async () => {
|
|
const created = await t.api.post('/v1/suppliers', { name: 'Old Supplier Name' })
|
|
const res = await t.api.patch(`/v1/suppliers/${created.data.id}`, { name: 'New Supplier Name', accountNumber: 'ACC-001' })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.name, 'New Supplier Name')
|
|
t.assert.equal(res.data.accountNumber, 'ACC-001')
|
|
})
|
|
|
|
t.test('soft-deletes a supplier', { tags: ['suppliers', 'delete'] }, async () => {
|
|
const created = await t.api.post('/v1/suppliers', { name: 'To Delete Sup' })
|
|
const res = await t.api.del(`/v1/suppliers/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.isActive, false)
|
|
})
|
|
|
|
// ─── Products ──────────────────────────────────────────────────────────────
|
|
|
|
t.test('creates a product', { tags: ['products', 'create'] }, async () => {
|
|
const res = await t.api.post('/v1/products', {
|
|
name: 'Violin 4/4 Student',
|
|
sku: 'VLN-44-TST',
|
|
brand: 'Eastman',
|
|
price: 399,
|
|
isSerialized: true,
|
|
isRental: true,
|
|
isActive: true,
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.name, 'Violin 4/4 Student')
|
|
t.assert.equal(res.data.sku, 'VLN-44-TST')
|
|
t.assert.equal(res.data.price, '399.00')
|
|
t.assert.equal(res.data.isSerialized, true)
|
|
t.assert.ok(res.data.id)
|
|
})
|
|
|
|
t.test('rejects product without name', { tags: ['products', 'create', 'validation'] }, async () => {
|
|
const res = await t.api.post('/v1/products', { sku: 'NO-NAME' })
|
|
t.assert.status(res, 400)
|
|
})
|
|
|
|
t.test('lists products with pagination', { tags: ['products', 'list'] }, async () => {
|
|
await t.api.post('/v1/products', { name: 'Prod List A', price: 100 })
|
|
await t.api.post('/v1/products', { name: 'Prod List B', price: 200 })
|
|
const res = await t.api.get('/v1/products', { page: 1, limit: 25 })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.length >= 2)
|
|
t.assert.ok(res.data.pagination)
|
|
})
|
|
|
|
t.test('searches products by name', { tags: ['products', 'list', 'search'] }, async () => {
|
|
await t.api.post('/v1/products', { name: 'SearchableViolinXYZ' })
|
|
const res = await t.api.get('/v1/products', { q: 'SearchableViolinXYZ' })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 1)
|
|
t.assert.equal(res.data.data[0].name, 'SearchableViolinXYZ')
|
|
})
|
|
|
|
t.test('filters products by categoryId', { tags: ['products', 'list', 'filter'] }, async () => {
|
|
const cat = await t.api.post('/v1/categories', { name: 'FilterCat' })
|
|
await t.api.post('/v1/products', { name: 'In Cat Prod', categoryId: cat.data.id })
|
|
await t.api.post('/v1/products', { name: 'No Cat Prod' })
|
|
const res = await t.api.get('/v1/products', { categoryId: cat.data.id })
|
|
t.assert.status(res, 200)
|
|
t.assert.ok(res.data.data.every((p: any) => p.categoryId === cat.data.id))
|
|
})
|
|
|
|
t.test('gets product by id', { tags: ['products', 'read'] }, async () => {
|
|
const created = await t.api.post('/v1/products', { name: 'Get By ID Prod' })
|
|
const res = await t.api.get(`/v1/products/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.name, 'Get By ID Prod')
|
|
})
|
|
|
|
t.test('returns 404 for missing product', { tags: ['products', 'read'] }, async () => {
|
|
const res = await t.api.get('/v1/products/a0000000-0000-0000-0000-999999999999')
|
|
t.assert.status(res, 404)
|
|
})
|
|
|
|
t.test('updates a product and records price history', { tags: ['products', 'update', 'price-history'] }, async () => {
|
|
const created = await t.api.post('/v1/products', { name: 'Price Test Prod', price: 100 })
|
|
const res = await t.api.patch(`/v1/products/${created.data.id}`, { price: 150, name: 'Price Test Prod Updated' })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.price, '150.00')
|
|
const history = await t.api.get(`/v1/products/${created.data.id}/price-history`)
|
|
t.assert.status(history, 200)
|
|
t.assert.equal(history.data.data.length, 1)
|
|
t.assert.equal(history.data.data[0].previousPrice, '100.00')
|
|
t.assert.equal(history.data.data[0].newPrice, '150.00')
|
|
})
|
|
|
|
t.test('price history is empty for new product', { tags: ['products', 'price-history'] }, async () => {
|
|
const created = await t.api.post('/v1/products', { name: 'No History Prod', price: 50 })
|
|
const res = await t.api.get(`/v1/products/${created.data.id}/price-history`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 0)
|
|
})
|
|
|
|
t.test('soft-deletes a product', { tags: ['products', 'delete'] }, async () => {
|
|
const created = await t.api.post('/v1/products', { name: 'To Delete Prod' })
|
|
const res = await t.api.del(`/v1/products/${created.data.id}`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.isActive, false)
|
|
})
|
|
|
|
// ─── Inventory Units ───────────────────────────────────────────────────────
|
|
|
|
t.test('creates a serialized unit', { tags: ['units', 'create'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Serial Prod', isSerialized: true })
|
|
const res = await t.api.post(`/v1/products/${prod.data.id}/units`, {
|
|
serialNumber: 'SN-001',
|
|
condition: 'new',
|
|
status: 'available',
|
|
purchaseCost: 185,
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.serialNumber, 'SN-001')
|
|
t.assert.equal(res.data.condition, 'new')
|
|
t.assert.equal(res.data.status, 'available')
|
|
t.assert.equal(res.data.purchaseCost, '185.00')
|
|
})
|
|
|
|
t.test('lists units for a product', { tags: ['units', 'list'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Multi Unit Prod', isSerialized: true })
|
|
await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-A', condition: 'new', status: 'available' })
|
|
await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-B', condition: 'good', status: 'rented' })
|
|
const res = await t.api.get(`/v1/products/${prod.data.id}/units`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 2)
|
|
})
|
|
|
|
t.test('updates a unit status', { tags: ['units', 'update'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Update Unit Prod', isSerialized: true })
|
|
const unit = await t.api.post(`/v1/products/${prod.data.id}/units`, { serialNumber: 'SN-UPD', condition: 'good', status: 'available' })
|
|
const res = await t.api.patch(`/v1/units/${unit.data.id}`, { status: 'rented', notes: 'Rented to customer A' })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.status, 'rented')
|
|
t.assert.equal(res.data.notes, 'Rented to customer A')
|
|
})
|
|
|
|
// ─── Product Suppliers ─────────────────────────────────────────────────────
|
|
|
|
t.test('links a supplier to a product', { tags: ['product-suppliers', 'create'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Linkable Prod' })
|
|
const sup = await t.api.post('/v1/suppliers', { name: 'Link Supplier' })
|
|
const res = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, {
|
|
supplierId: sup.data.id,
|
|
supplierSku: 'SUP-SKU-001',
|
|
isPreferred: true,
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.supplierId, sup.data.id)
|
|
t.assert.equal(res.data.supplierSku, 'SUP-SKU-001')
|
|
t.assert.equal(res.data.isPreferred, true)
|
|
})
|
|
|
|
t.test('lists suppliers for a product with joined supplier details', { tags: ['product-suppliers', 'list'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Supplier List Prod' })
|
|
const sup1 = await t.api.post('/v1/suppliers', { name: 'Supplier Alpha', paymentTerms: 'Net 30' })
|
|
const sup2 = await t.api.post('/v1/suppliers', { name: 'Supplier Beta' })
|
|
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup1.data.id, isPreferred: true })
|
|
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup2.data.id, isPreferred: false })
|
|
const res = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 2)
|
|
// preferred comes first
|
|
t.assert.equal(res.data.data[0].supplierName, 'Supplier Alpha')
|
|
t.assert.equal(res.data.data[0].isPreferred, true)
|
|
t.assert.equal(res.data.data[0].supplierPaymentTerms, 'Net 30')
|
|
t.assert.equal(res.data.data[1].supplierName, 'Supplier Beta')
|
|
})
|
|
|
|
t.test('preferred flag moves to new supplier when set', { tags: ['product-suppliers', 'preferred'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Preferred Test Prod' })
|
|
const sup1 = await t.api.post('/v1/suppliers', { name: 'Pref Sup A' })
|
|
const sup2 = await t.api.post('/v1/suppliers', { name: 'Pref Sup B' })
|
|
const link1 = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup1.data.id, isPreferred: true })
|
|
t.assert.equal(link1.data.isPreferred, true)
|
|
// Link second supplier as preferred — first should lose preferred status
|
|
await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup2.data.id, isPreferred: true })
|
|
const list = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
|
|
const a = list.data.data.find((s: any) => s.supplierId === sup1.data.id)
|
|
const b = list.data.data.find((s: any) => s.supplierId === sup2.data.id)
|
|
t.assert.equal(a.isPreferred, false)
|
|
t.assert.equal(b.isPreferred, true)
|
|
})
|
|
|
|
t.test('updates a product-supplier link', { tags: ['product-suppliers', 'update'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Update Link Prod' })
|
|
const sup = await t.api.post('/v1/suppliers', { name: 'Update Link Sup' })
|
|
const link = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup.data.id, supplierSku: 'OLD-SKU' })
|
|
const res = await t.api.patch(`/v1/products/${prod.data.id}/suppliers/${link.data.id}`, { supplierSku: 'NEW-SKU' })
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.supplierSku, 'NEW-SKU')
|
|
})
|
|
|
|
t.test('removes a supplier link', { tags: ['product-suppliers', 'delete'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Remove Link Prod' })
|
|
const sup = await t.api.post('/v1/suppliers', { name: 'Remove Link Sup' })
|
|
const link = await t.api.post(`/v1/products/${prod.data.id}/suppliers`, { supplierId: sup.data.id })
|
|
const res = await t.api.del(`/v1/products/${prod.data.id}/suppliers/${link.data.id}`)
|
|
t.assert.status(res, 200)
|
|
const list = await t.api.get(`/v1/products/${prod.data.id}/suppliers`)
|
|
t.assert.equal(list.data.data.length, 0)
|
|
})
|
|
|
|
t.test('returns 404 for missing product-supplier link update', { tags: ['product-suppliers', 'update'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Missing Link Prod' })
|
|
const res = await t.api.patch(`/v1/products/${prod.data.id}/suppliers/a0000000-0000-0000-0000-999999999999`, { supplierSku: 'X' })
|
|
t.assert.status(res, 404)
|
|
})
|
|
|
|
// ─── Stock Receipts ────────────────────────────────────────────────────────
|
|
|
|
t.test('records a stock receipt and increments qty_on_hand', { tags: ['stock-receipts', 'create'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Rosin Stock', qtyOnHand: 10 })
|
|
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
|
|
qty: 5,
|
|
costPerUnit: 6.50,
|
|
receivedDate: '2026-03-15',
|
|
invoiceNumber: 'INV-2026-001',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.qty, 5)
|
|
t.assert.equal(res.data.costPerUnit, '6.50')
|
|
t.assert.equal(res.data.totalCost, '32.50')
|
|
t.assert.equal(res.data.invoiceNumber, 'INV-2026-001')
|
|
// qty_on_hand should have increased
|
|
const updated = await t.api.get(`/v1/products/${prod.data.id}`)
|
|
t.assert.equal(updated.data.qtyOnHand, 15)
|
|
})
|
|
|
|
t.test('records receipt with supplier link', { tags: ['stock-receipts', 'create'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Strings Stock' })
|
|
const sup = await t.api.post('/v1/suppliers', { name: 'Receipt Supplier' })
|
|
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
|
|
supplierId: sup.data.id,
|
|
qty: 12,
|
|
costPerUnit: 19.50,
|
|
receivedDate: '2026-03-20',
|
|
})
|
|
t.assert.status(res, 201)
|
|
t.assert.equal(res.data.supplierId, sup.data.id)
|
|
})
|
|
|
|
t.test('does not increment qty for serialized products', { tags: ['stock-receipts', 'create'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Serial Stock', isSerialized: true, qtyOnHand: 0 })
|
|
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, {
|
|
qty: 1,
|
|
costPerUnit: 185,
|
|
receivedDate: '2026-03-01',
|
|
})
|
|
const updated = await t.api.get(`/v1/products/${prod.data.id}`)
|
|
t.assert.equal(updated.data.qtyOnHand, 0)
|
|
})
|
|
|
|
t.test('lists stock receipts for a product', { tags: ['stock-receipts', 'list'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'List Receipts Prod' })
|
|
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 10, costPerUnit: 5, receivedDate: '2026-01-10' })
|
|
await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 20, costPerUnit: 4.75, receivedDate: '2026-02-15' })
|
|
const res = await t.api.get(`/v1/products/${prod.data.id}/stock-receipts`)
|
|
t.assert.status(res, 200)
|
|
t.assert.equal(res.data.data.length, 2)
|
|
// most recent date first
|
|
t.assert.equal(res.data.data[0].receivedDate, '2026-02-15')
|
|
})
|
|
|
|
t.test('rejects receipt with missing required fields', { tags: ['stock-receipts', 'validation'] }, async () => {
|
|
const prod = await t.api.post('/v1/products', { name: 'Invalid Receipt Prod' })
|
|
const res = await t.api.post(`/v1/products/${prod.data.id}/stock-receipts`, { qty: 5 })
|
|
t.assert.status(res, 400)
|
|
})
|
|
})
|