Files
lunarfront-app/packages/backend/api-tests/suites/inventory.ts
Ryan Moon 5f5ba9e4a2 Build inventory frontend and stock management features
- 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
2026-03-30 20:12:07 -05:00

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)
})
})