diff --git a/package.json b/package.json index eee6dda..63f0f82 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "init-schema": "node scripts/init-schema.js", "purge-db": "node scripts/purge-db.js", "reset-db": "node scripts/reset-db.js", + "test": "node --test \"test/*.test.js\"", "test:api": "node scripts/test-api.js", "test:benchmark": "node scripts/benchmark.js" }, diff --git a/test/article.test.js b/test/article.test.js new file mode 100644 index 0000000..3e69b4b --- /dev/null +++ b/test/article.test.js @@ -0,0 +1,648 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestStore, createTenant, createArticle, makePng } from './helpers.js'; + +describe('store.article', () => { + let ctx; + let store; + let tenant; + let other; + let gid; + + before(async () => { + ctx = await createTestStore('dash-article-'); + store = ctx.store; + tenant = await createTenant(store, { name: 'Article Test GmbH' }); + other = await createTenant(store, { name: 'Article Other GmbH' }); + gid = tenant.googleAuthId; + }); + + after(async () => { + await ctx.cleanup(); + }); + + const get = async (sku, who = gid) => (await store.article.getBySku(who, sku)).article; + const historyTexts = async (sku) => + (await store.article.getHistoryBySku(gid, sku)).map((h) => h.text); + + describe('createUnderParent', () => { + it('creates an article with auto-SKU, default unit and untracked stock', async () => { + const art = await createArticle(store, tenant, { name: 'Cola Zero', desc: '

Lecker

', seo: 'cola' }); + + assert.equal(typeof art.id, 'number'); + assert.equal(art.parentId, tenant.rootCategoryId); + assert.equal(art.name, 'Cola Zero'); + assert.equal(art.track_stock, false); + assert.ok(typeof art.sku === 'string' && art.sku.length > 0); + + const detail = await get(art.sku); + assert.equal(detail.id, art.id); + assert.equal(detail.name, 'Cola Zero'); + assert.equal(detail.slug, 'cola-zero'); + assert.equal(detail.desc, '

Lecker

'); + assert.equal(detail.seo, 'cola'); + assert.equal(detail.unitamount, 1); + assert.equal(detail.weight, 0); + assert.equal(detail.track_stock, false); + assert.deepEqual(detail.categoryIds, [tenant.rootCategoryId]); + assert.deepEqual(detail.eans, []); + assert.deepEqual(detail.prices, []); + assert.deepEqual(detail.photos, []); + assert.equal(detail.brand, null); + assert.equal(detail.parentArticleId, null); + assert.ok(detail.unit.si, 'default unit is assigned'); + }); + + it('allocates distinct SKUs and unique slugs for equal names', async () => { + const a = await createArticle(store, tenant, { name: 'Dupe Name' }); + const b = await createArticle(store, tenant, { name: 'Dupe Name' }); + assert.notEqual(a.sku, b.sku); + assert.equal((await get(a.sku)).slug, 'dupe-name'); + assert.equal((await get(b.sku)).slug, 'dupe-name-2'); + }); + + it('accepts an explicit SKU and rejects duplicates', async () => { + const art = await createArticle(store, tenant, { sku: 'EXPLICIT-1' }); + assert.equal(art.sku, 'EXPLICIT-1'); + await assert.rejects( + () => createArticle(store, tenant, { sku: 'EXPLICIT-1' }), + { message: 'SKU "EXPLICIT-1" already exists' }, + ); + }); + + it('records a creation history entry', async () => { + const art = await createArticle(store, tenant, { name: 'History Born' }); + const history = await store.article.getHistoryBySku(gid, art.sku); + assert.ok(history.length >= 1); + assert.ok(history.every((h) => typeof h.text === 'string' && h.date)); + }); + + it('rejects a parent category of another company', async () => { + await assert.rejects( + () => createArticle(store, tenant, { parentId: other.rootCategoryId }), + { message: 'Not authorized' }, + ); + }); + }); + + describe('stock tracking', () => { + it('cannot enable tracking without a warehouse', async () => { + const fresh = await createTenant(store, { name: 'No Warehouse GmbH' }); + const msg = { message: 'At least one warehouse is required to enable stock tracking' }; + + await assert.rejects(() => createArticle(store, fresh, { track_stock: true }), msg); + const art = await createArticle(store, fresh); + await assert.rejects(() => store.article.setTrackStockBySku(fresh.googleAuthId, art.sku, true), msg); + await assert.rejects( + () => store.article.updateBySku(fresh.googleAuthId, art.sku, { track_stock: true }), + msg, + ); + }); + + it('setTrackStockBySku toggles the flag and validates input', async () => { + await store.warehouse.create(gid, { name: 'Article WH' }); + const art = await createArticle(store, tenant); + + const on = await store.article.setTrackStockBySku(gid, art.sku, true); + assert.equal(on.track_stock, true); + assert.equal((await get(art.sku)).track_stock, true); + + const off = await store.article.setTrackStockBySku(gid, art.sku, 0); + assert.equal(off.track_stock, false); + + await assert.rejects( + () => store.article.setTrackStockBySku(gid, art.sku, 'yes'), + { message: 'Invalid track_stock' }, + ); + }); + }); + + describe('updateBySku', () => { + it('updates name, desc, seo and unitamount and records history', async () => { + const art = await createArticle(store, tenant, { name: 'Before' }); + await store.article.updateBySku(gid, art.sku, { + name: 'After', + desc: 'new desc', + seo: 'new seo', + unitamount: 0.5, + }); + + const detail = await get(art.sku); + assert.equal(detail.name, 'After'); + assert.equal(detail.slug, 'after'); + assert.equal(detail.desc, 'new desc'); + assert.equal(detail.seo, 'new seo'); + assert.equal(detail.unitamount, 0.5); + + const texts = await historyTexts(art.sku); + assert.ok(texts.some((t) => t.includes('Name geändert von "Before" auf "After"'))); + }); + + it('validates the patch', async () => { + const art = await createArticle(store, tenant); + const upd = (patch) => store.article.updateBySku(gid, art.sku, patch); + + await assert.rejects(() => upd({}), { message: 'Name required' }); + await assert.rejects(() => upd({ name: ' ' }), { message: 'Name required' }); + await assert.rejects(() => upd({ unitamount: 0 }), { message: 'Invalid unitamount' }); + await assert.rejects(() => upd({ unitamount: Number.NaN }), { message: 'Invalid unitamount' }); + await assert.rejects(() => upd({ track_stock: 'x' }), { message: 'Invalid track_stock' }); + await assert.rejects(() => upd({ unitSi: 'does-not-exist' }), { message: 'Unit not found' }); + }); + + it('rejects unknown SKUs', async () => { + await assert.rejects( + () => store.article.updateBySku(gid, 'NOPE-404', { name: 'x' }), + { message: 'Article not found' }, + ); + }); + }); + + describe('setEansBySku', () => { + it('replaces the EAN list (trimmed)', async () => { + const art = await createArticle(store, tenant); + let res = await store.article.setEansBySku(gid, art.sku, [' 4001234567890 ', '4001234567891']); + assert.deepEqual(res.eans, ['4001234567890', '4001234567891']); + + res = await store.article.setEansBySku(gid, art.sku, ['4001234567892']); + assert.deepEqual(res.eans, ['4001234567892']); + assert.deepEqual((await get(art.sku)).eans, ['4001234567892']); + + res = await store.article.setEansBySku(gid, art.sku, []); + assert.deepEqual(res.eans, []); + }); + + it('rejects invalid input', async () => { + const art = await createArticle(store, tenant); + const err = { message: 'Invalid eans' }; + await assert.rejects(() => store.article.setEansBySku(gid, art.sku, 'x'), err); + await assert.rejects(() => store.article.setEansBySku(gid, art.sku, ['']), err); + await assert.rejects(() => store.article.setEansBySku(gid, art.sku, [123]), err); + await assert.rejects(() => store.article.setEansBySku(gid, art.sku, ['1', ' 1 ']), err); + }); + + it('rejects an EAN used by another article of the company', async () => { + const a = await createArticle(store, tenant); + const b = await createArticle(store, tenant); + await store.article.setEansBySku(gid, a.sku, ['4009999999999']); + await assert.rejects( + () => store.article.setEansBySku(gid, b.sku, ['4009999999999']), + { message: 'EAN already in use' }, + ); + assert.deepEqual((await get(b.sku)).eans, [], 'failed write persisted nothing'); + }); + }); + + describe('setPricesBySku', () => { + it('stores net prices with tax rate and minmultiplier', async () => { + const art = await createArticle(store, tenant); + const res = await store.article.setPricesBySku(gid, art.sku, [ + { net: 10, taxRate: 19 }, + { net: 9, tax: 19, minmultiplier: 10 }, + ]); + assert.equal(res.prices.length, 2); + assert.deepEqual( + res.prices.map((p) => [p.minmultiplier, p.net, p.taxRate]), + [[1, 10, 19], [10, 9, 19]], + ); + }); + + it('converts gross to net', async () => { + const art = await createArticle(store, tenant); + const res = await store.article.setPricesBySku(gid, art.sku, [{ gross: 11.9, taxRate: 19 }]); + assert.equal(res.prices.length, 1); + assert.equal(res.prices[0].net, 10); + }); + + it('replaces previous prices', async () => { + const art = await createArticle(store, tenant); + await store.article.setPricesBySku(gid, art.sku, [{ net: 1, taxRate: 7 }]); + const res = await store.article.setPricesBySku(gid, art.sku, [{ net: 2, taxRate: 7 }]); + assert.deepEqual(res.prices.map((p) => p.net), [2]); + }); + + it('rejects invalid prices', async () => { + const art = await createArticle(store, tenant); + const err = { message: 'Invalid prices' }; + const set = (prices) => store.article.setPricesBySku(gid, art.sku, prices); + await assert.rejects(() => set('x'), err); + await assert.rejects(() => set([{ net: 1 }]), err); + await assert.rejects(() => set([{ net: 1, taxRate: -1 }]), err); + await assert.rejects(() => set([{ taxRate: 19 }]), err); + await assert.rejects(() => set([{ net: -1, taxRate: 19 }]), err); + await assert.rejects(() => set([{ net: 1, taxRate: 19, minmultiplier: 0 }]), err); + await assert.rejects(() => set([{ net: 1, taxRate: 19 }, { net: 2, taxRate: 19 }]), err); + }); + }); + + describe('setCategoryIdsBySku', () => { + it('replaces category membership', async () => { + const art = await createArticle(store, tenant); + const cat = await store.category.createUnderParent({ googleAuthId: gid, parentId: tenant.rootCategoryId, name: 'Art Cat' }); + + const res = await store.article.setCategoryIdsBySku(gid, art.sku, [cat.id, tenant.rootCategoryId]); + assert.deepEqual([...res.categoryIds].sort(), [cat.id, tenant.rootCategoryId].sort()); + + await store.article.setCategoryIdsBySku(gid, art.sku, [cat.id]); + assert.deepEqual((await get(art.sku)).categoryIds, [cat.id]); + }); + + it('validates the category ids', async () => { + const art = await createArticle(store, tenant); + const set = (ids) => store.article.setCategoryIdsBySku(gid, art.sku, ids); + await assert.rejects(() => set('x'), { message: 'Invalid categoryIds' }); + await assert.rejects(() => set([]), { message: 'Article must have at least one category' }); + await assert.rejects(() => set([tenant.rootCategoryId, tenant.rootCategoryId]), { message: 'Invalid categoryIds' }); + await assert.rejects(() => set([other.rootCategoryId]), /Invalid categoryIds|Not authorized/); + assert.deepEqual((await get(art.sku)).categoryIds, [tenant.rootCategoryId]); + }); + }); + + describe('setWeightBySku', () => { + it('sets the weight and validates it', async () => { + const art = await createArticle(store, tenant); + const res = await store.article.setWeightBySku(gid, art.sku, 1.5); + assert.equal(res.weight, 1.5); + assert.equal((await get(art.sku)).weight, 1.5); + + await assert.rejects(() => store.article.setWeightBySku(gid, art.sku, -1), { message: 'Invalid weight' }); + await assert.rejects(() => store.article.setWeightBySku(gid, art.sku, 'heavy'), { message: 'Invalid weight' }); + }); + }); + + describe('setBrandIdBySku', () => { + it('sets and clears the brand', async () => { + const art = await createArticle(store, tenant); + const brand = await store.brand.create(gid, { name: 'Acme' }); + + await store.article.setBrandIdBySku(gid, art.sku, brand.id); + let detail = await get(art.sku); + assert.equal(detail.brand.id, brand.id); + assert.equal(detail.brand.name, 'Acme'); + + await store.article.setBrandIdBySku(gid, art.sku, null); + detail = await get(art.sku); + assert.equal(detail.brand, null); + }); + + it('rejects invalid and foreign brands', async () => { + const art = await createArticle(store, tenant); + const foreignBrand = await store.brand.create(other.googleAuthId, { name: 'Foreign Brand' }); + + await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, 'x'), { message: 'Invalid brandId' }); + await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, 0), { message: 'Invalid brandId' }); + await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, 999999), { message: 'Brand not found' }); + await assert.rejects(() => store.article.setBrandIdBySku(gid, art.sku, foreignBrand.id), { message: 'Brand not found' }); + }); + }); + + describe('getHistoryBySku', () => { + it('honours the limit option', async () => { + const art = await createArticle(store, tenant, { name: 'H0' }); + await store.article.updateBySku(gid, art.sku, { name: 'H1' }); + await store.article.updateBySku(gid, art.sku, { name: 'H2' }); + + const all = await store.article.getHistoryBySku(gid, art.sku); + assert.ok(all.length >= 3); + const limited = await store.article.getHistoryBySku(gid, art.sku, { limit: 1 }); + assert.equal(limited.length, 1); + }); + }); + + describe('listSearchIndex / listExport', () => { + it('includes sku, name, eans and categories', async () => { + const art = await createArticle(store, tenant, { name: 'Indexed' }); + await store.article.setEansBySku(gid, art.sku, ['4000000000017']); + + const index = await store.article.listSearchIndex(gid); + const entry = index.find((e) => e.sku === art.sku); + assert.ok(entry); + assert.equal(entry.name, 'Indexed'); + assert.deepEqual(entry.eans, ['4000000000017']); + assert.deepEqual(entry.categoryIds, [tenant.rootCategoryId]); + + const exported = await store.article.listExport(gid, null); + assert.ok(exported.some((e) => e.sku === art.sku)); + }); + + it('scopes export to a category', async () => { + const cat = await store.category.createUnderParent({ googleAuthId: gid, parentId: tenant.rootCategoryId, name: 'Export Cat' }); + const inside = await createArticle(store, tenant, { parentId: cat.id }); + const outside = await createArticle(store, tenant); + + const exported = await store.article.listExport(gid, cat.id); + const skus = exported.map((e) => e.sku); + assert.ok(skus.includes(inside.sku)); + assert.ok(!skus.includes(outside.sku)); + }); + + it('does not leak articles of other companies', async () => { + const foreign = await createArticle(store, other, { name: 'Foreign Indexed', sku: 'FOREIGN-INDEXED' }); + const index = await store.article.listSearchIndex(gid); + assert.ok(!index.some((e) => e.sku === foreign.sku)); + await assert.rejects(() => store.article.listExport(gid, other.rootCategoryId), /Not authorized|not found/); + }); + }); + + describe('delete', () => { + it('deletes an article', async () => { + const art = await createArticle(store, tenant); + await store.article.delete(gid, art.sku); + await assert.rejects(() => get(art.sku), { message: 'Article not found' }); + }); + + it('rejects unknown SKUs', async () => { + await assert.rejects(() => store.article.delete(gid, 'NOPE-404'), { message: 'Article not found' }); + }); + + it('cannot delete articles of another company', async () => { + const foreign = await createArticle(store, other, { sku: 'FOREIGN-DELETE' }); + await assert.rejects(() => store.article.delete(gid, foreign.sku), /Article not found|Not authorized/); + assert.ok(await get(foreign.sku, other.googleAuthId)); + }); + }); + + describe('variants (parent / child)', () => { + let attr; + + before(async () => { + attr = await store.attributename.create(gid, { name: 'Farbe' }); + }); + + const makeParent = async (name = 'T-Shirt') => { + const parent = await createArticle(store, tenant, { name }); + await store.article.toggleParent(gid, parent.sku, { isParent: true, attributeNameId: attr.id }); + return parent; + }; + + it('toggleParent requires an attribute name', async () => { + const art = await createArticle(store, tenant); + await assert.rejects( + () => store.article.toggleParent(gid, art.sku, { isParent: true }), + { message: 'attributeNameId is required to become a parent article' }, + ); + }); + + it('addChild creates sequential child SKUs and listChildren lists them', async () => { + const parent = await makeParent(); + const red = await store.article.addChild(gid, parent.sku, { value: 'Rot' }); + const blue = await store.article.addChild(gid, parent.sku, { value: 'Blau' }); + + assert.equal(red.article.sku, `${parent.sku}-1`); + assert.equal(blue.article.sku, `${parent.sku}-2`); + + const children = await store.article.listChildren(gid, parent.sku); + assert.deepEqual(children.map((c) => c.sku), [red.article.sku, blue.article.sku]); + assert.deepEqual(children.map((c) => c.parentsort), [0, 1]); + assert.deepEqual(children.map((c) => c.attributeValue), ['Rot', 'Blau']); + + const detail = await get(parent.sku); + assert.equal(detail.isParent, true); + assert.equal(detail.childCount, 2); + assert.equal((await get(red.article.sku)).parentSku, parent.sku); + }); + + it('addChild rejects duplicates, explicit SKUs and non-parents', async () => { + const parent = await makeParent('Hoodie'); + await store.article.addChild(gid, parent.sku, { value: 'Rot' }); + + await assert.rejects( + () => store.article.addChild(gid, parent.sku, { value: 'Rot' }), + { message: 'Variant with this attribute value already exists' }, + ); + await assert.rejects( + () => store.article.addChild(gid, parent.sku, { value: 'Lila', sku: 'CUSTOM' }), + /sku is not allowed when creating a child article/, + ); + await assert.rejects( + () => store.article.addChild(gid, parent.sku, {}), + { message: 'Attribute value is required for child article' }, + ); + + const plain = await createArticle(store, tenant); + await assert.rejects( + () => store.article.addChild(gid, plain.sku, { value: 'Rot' }), + { message: 'Article is not a parent article' }, + ); + await assert.rejects( + () => store.article.addChild(gid, 'NOPE-404', { value: 'Rot' }), + { message: 'Article not found' }, + ); + }); + + it('parent articles cannot have prices', async () => { + const parent = await makeParent('Priceless'); + await assert.rejects( + () => store.article.setPricesBySku(gid, parent.sku, [{ net: 1, taxRate: 19 }]), + { message: 'A parent article cannot have prices' }, + ); + }); + + it('removeChild unlinks (re-compacting parentsort) or deletes', async () => { + const parent = await makeParent('Cap'); + const c1 = await store.article.addChild(gid, parent.sku, { value: 'A' }); + const c2 = await store.article.addChild(gid, parent.sku, { value: 'B' }); + const c3 = await store.article.addChild(gid, parent.sku, { value: 'C' }); + + const unlinked = await store.article.removeChild(gid, parent.sku, c2.article.sku); + assert.equal(unlinked.success, true); + assert.equal(unlinked.deleted, false); + assert.equal((await get(c2.article.sku)).parentArticleId, null); + + let children = await store.article.listChildren(gid, parent.sku); + assert.deepEqual(children.map((c) => c.sku), [c1.article.sku, c3.article.sku]); + assert.deepEqual(children.map((c) => c.parentsort), [0, 1]); + + const deleted = await store.article.removeChild(gid, parent.sku, c3.article.sku, { deleteArticle: true }); + assert.equal(deleted.deleted, true); + await assert.rejects(() => get(c3.article.sku), { message: 'Article not found' }); + + await assert.rejects( + () => store.article.removeChild(gid, parent.sku, c2.article.sku), + { message: 'Child article does not belong to this parent' }, + ); + children = await store.article.listChildren(gid, parent.sku); + assert.equal(children.length, 1); + }); + + it('attachChild attaches an existing article and guards against misuse', async () => { + const parent = await makeParent('Sock'); + const loose = await createArticle(store, tenant, { name: 'Loose Sock' }); + + const res = await store.article.attachChild(gid, parent.sku, { sku: loose.sku, value: 'Grün' }); + assert.equal(res.article.sku, loose.sku); + assert.equal(res.article.parentArticleId, parent.id); + assert.ok(res.children.some((c) => c.sku === loose.sku)); + + // idempotent on same parent + const again = await store.article.attachChild(gid, parent.sku, { sku: loose.sku, value: 'Grün' }); + assert.equal(again.article.sku, loose.sku); + + await assert.rejects( + () => store.article.attachChild(gid, parent.sku, {}), + { message: 'sku is required to attach an existing article' }, + ); + await assert.rejects( + () => store.article.attachChild(gid, parent.sku, { sku: parent.sku, value: 'X' }), + { message: 'An article cannot be a child of itself' }, + ); + + const otherParent = await makeParent('Other Sock'); + await assert.rejects( + () => store.article.attachChild(gid, otherParent.sku, { sku: loose.sku, value: 'Gelb' }), + { message: 'Article already belongs to another parent' }, + ); + }); + + it('cannot disable parent mode while children exist', async () => { + const parent = await makeParent('Scarf'); + await store.article.addChild(gid, parent.sku, { value: 'Rot' }); + await assert.rejects( + () => store.article.toggleParent(gid, parent.sku, { isParent: false }), + { message: 'Cannot disable parent mode on an article that still has children' }, + ); + }); + + it('delete refuses parents with children unless cascade', async () => { + const parent = await makeParent('Glove'); + const child = await store.article.addChild(gid, parent.sku, { value: 'Rot' }); + + await assert.rejects(() => store.article.delete(gid, parent.sku), { message: 'Article has children' }); + await store.article.delete(gid, parent.sku, { cascade: true }); + await assert.rejects(() => get(parent.sku), { message: 'Article not found' }); + await assert.rejects(() => get(child.article.sku), { message: 'Article not found' }); + }); + + it('setParentBySku links and validates the parent', async () => { + const parent = await createArticle(store, tenant, { name: 'Compound V' }); + const child = await createArticle(store, tenant, { name: 'Compound K' }); + + const linked = await store.article.setParentBySku(gid, child.sku, parent.sku); + assert.equal(linked.parentArticleId, parent.id); + assert.equal(linked.parentSku, parent.sku); + assert.equal((await get(parent.sku)).isParent, true); + + await assert.rejects(() => store.article.setParentBySku(gid, child.sku, 'NOPE-404'), { message: 'Parent not found' }); + await assert.rejects(() => store.article.setParentBySku(gid, child.sku, child.sku), { message: 'Invalid parent' }); + }); + + it('setParentBySku unlinks with null', { + todo: 'BUG src/store/article/setParentBySku.js:66 — insertHistorySql is not defined (ReferenceError when unlinking a child that has a parent)', + }, async () => { + const parent = await createArticle(store, tenant, { name: 'Unlink V' }); + const child = await createArticle(store, tenant, { name: 'Unlink K' }); + await store.article.setParentBySku(gid, child.sku, parent.sku); + + const unlinked = await store.article.setParentBySku(gid, child.sku, null); + assert.equal(unlinked.parentArticleId, null); + }); + }); + + describe('importRows', () => { + it('imports rows, resolves categories/brands/prices/eans and links parents in a 2nd pass', async () => { + const result = await store.article.importRows({ + googleAuthId: gid, + rows: [ + // child appears before its parent + { sku: 'IMP-1-1', name: 'Imported 1g', vk: 'K', parentCategory: 'Import | Flowers', brand: 'ImportBrand', net: '11,00', tax: 19, eans: ['4262557690455'], parentSku: 'IMP-1' }, + { sku: 'IMP-1', name: 'Imported', vk: 'V', parentCategory: 'Import | Flowers' }, + { sku: '', name: 'No SKU' }, + ], + }); + + assert.deepEqual(result.imported.map((r) => r.sku).sort(), ['IMP-1', 'IMP-1-1']); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].error, 'Invalid row'); + + const child = await get('IMP-1-1'); + assert.equal(child.name, 'Imported 1g'); + assert.equal(child.brand.name, 'ImportBrand'); + assert.equal(child.prices[0].net, 11); + assert.equal(child.prices[0].taxRate, 19); + assert.deepEqual(child.eans, ['4262557690455']); + assert.equal(child.parentSku, 'IMP-1'); + + const { tree } = await store.category.getTreeForCompany(gid); + const leaf = tree.find((c) => c.id === child.categoryIds[0]); + assert.equal(leaf.name, 'Flowers'); + }); + + it('skips existing SKUs and requires a parent category', async () => { + const existing = await createArticle(store, tenant); + const result = await store.article.importRows({ + googleAuthId: gid, + rows: [ + { sku: existing.sku, name: 'Again', parentId: tenant.rootCategoryId }, + { sku: 'IMP-NOCAT', name: 'No Category' }, + ], + }); + assert.deepEqual(result.skipped.map((s) => s.sku), [existing.sku]); + assert.equal(result.errors.length, 1); + assert.equal(result.errors[0].error, 'Parent category required'); + assert.equal(result.imported.length, 0); + }); + + it('rejects non-array rows', async () => { + await assert.rejects( + () => store.article.importRows({ googleAuthId: gid, rows: 'nope' }), + { message: 'Invalid rows' }, + ); + }); + }); + + describe('images', () => { + it('adds, orders and deletes images', async () => { + const art = await createArticle(store, tenant); + const img1 = await store.article.addImage(gid, art.sku, await makePng({ r: 10 })); + const img2 = await store.article.addImage(gid, art.sku, await makePng({ r: 250, width: 80 })); + + assert.notEqual(img1.fullHash, img2.fullHash); + assert.equal(img1.full.w, 64); + assert.equal(img1.full.h, 48); + assert.ok(img1.thumb.w > 0 && img1.smallThumb.w > 0); + + let photos = (await get(art.sku)).photos; + assert.deepEqual(photos.map((p) => p.fullHash), [img1.fullHash, img2.fullHash]); + + const ordered = await store.article.orderImages(gid, art.sku, { fullHashes: [img2.fullHash, img1.fullHash] }); + assert.deepEqual(ordered.fullHashes, [img2.fullHash, img1.fullHash]); + photos = (await get(art.sku)).photos; + assert.deepEqual(photos.map((p) => p.fullHash), [img2.fullHash, img1.fullHash]); + + await assert.rejects( + () => store.article.orderImages(gid, art.sku, { fullHashes: [img1.fullHash] }), + { message: 'Invalid image order' }, + ); + await assert.rejects( + () => store.article.orderImages(gid, art.sku, { fullHashes: [img1.fullHash, 'bogus'] }), + { message: 'Invalid image order' }, + ); + + await store.article.deleteImage(gid, art.sku, img2.fullHash); + photos = (await get(art.sku)).photos; + assert.deepEqual(photos.map((p) => p.fullHash), [img1.fullHash]); + + await assert.rejects( + () => store.article.deleteImage(gid, art.sku, img2.fullHash), + { message: 'Image not found' }, + ); + }); + }); + + describe('tenant isolation', () => { + it('getBySku / setters do not work across companies', async () => { + const foreign = await createArticle(store, other, { name: 'Foreign', sku: 'FOREIGN-ISO' }); + const denied = /Article not found|Not authorized/; + + await assert.rejects(() => store.article.getBySku(gid, foreign.sku), denied); + await assert.rejects(() => store.article.updateBySku(gid, foreign.sku, { name: 'Hijack' }), denied); + await assert.rejects(() => store.article.setEansBySku(gid, foreign.sku, ['4011111111111']), denied); + await assert.rejects(() => store.article.setPricesBySku(gid, foreign.sku, [{ net: 1, taxRate: 19 }]), denied); + await assert.rejects(() => store.article.setWeightBySku(gid, foreign.sku, 2), denied); + + const untouched = await get(foreign.sku, other.googleAuthId); + assert.equal(untouched.name, 'Foreign'); + assert.deepEqual(untouched.eans, []); + }); + }); +}); diff --git a/test/category.test.js b/test/category.test.js new file mode 100644 index 0000000..e29e9c5 --- /dev/null +++ b/test/category.test.js @@ -0,0 +1,335 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestStore, createTenant, createArticle, makePng } from './helpers.js'; + +describe('store.category', () => { + let ctx; + let store; + let tenant; + let other; + + before(async () => { + ctx = await createTestStore('dash-category-'); + store = ctx.store; + tenant = await createTenant(store, { name: 'Category Test GmbH' }); + other = await createTenant(store, { name: 'Category Other GmbH' }); + }); + + after(async () => { + await ctx.cleanup(); + }); + + const create = (name, parentId = tenant.rootCategoryId, who = tenant) => + store.category.createUnderParent({ googleAuthId: who.googleAuthId, parentId, name }); + + const findInTree = async (id, who = tenant) => { + const { tree } = await store.category.getTreeForCompany(who.googleAuthId); + return tree.find((c) => c.id === id); + }; + + describe('createUnderParent', () => { + it('creates a child with name, slug and incrementing sort', async () => { + const parent = await create('Create Parent'); + const a = await create('Getränke & Säfte', parent.id); + const b = await create('Snacks', parent.id); + + assert.equal(a.parentId, parent.id); + assert.equal(a.name, 'Getränke & Säfte'); + assert.equal(a.slug, 'getraenke-saefte'); + assert.equal(typeof a.id, 'number'); + assert.ok(b.sort > a.sort, 'second sibling gets a higher sort'); + }); + + it('rejects an unknown parent', async () => { + await assert.rejects(() => create('Orphan', 999999), { message: 'Parent not found' }); + }); + + it('rejects a parent of another company', async () => { + await assert.rejects(() => create('Intruder', other.rootCategoryId), { + message: 'Not authorized', + }); + }); + }); + + describe('getTreeForCompany', () => { + it('returns root + created categories with article counts', async () => { + const cat = await create('Tree Cat'); + await createArticle(store, tenant, { parentId: cat.id, name: 'Tree Article' }); + + const result = await store.category.getTreeForCompany(tenant.googleAuthId); + assert.equal(typeof result.languageId, 'number'); + assert.ok(Array.isArray(result.tree)); + + const root = result.tree.find((c) => c.id === tenant.rootCategoryId); + assert.ok(root, 'root category is part of the tree'); + assert.equal(root.parentId, null); + + const node = result.tree.find((c) => c.id === cat.id); + assert.equal(node.parentId, tenant.rootCategoryId); + assert.equal(node.name, 'Tree Cat'); + assert.equal(node.articleCount, 1); + assert.deepEqual(node.posterminalIds, []); + assert.ok(result.articleCount >= 1); + }); + + it('does not leak categories of other companies', async () => { + const foreign = await create('Foreign Only', other.rootCategoryId, other); + assert.equal(await findInTree(foreign.id), undefined); + assert.ok(await findInTree(foreign.id, other)); + }); + }); + + describe('getCategoryArticles', () => { + it('lists only the articles of that category', async () => { + const cat = await create('Articles Cat'); + const art = await createArticle(store, tenant, { parentId: cat.id, name: 'Listed' }); + await createArticle(store, tenant, { name: 'Not Listed' }); + + const list = await store.category.getCategoryArticles(tenant.googleAuthId, cat.id); + assert.equal(list.length, 1); + assert.equal(list[0].sku, art.sku); + assert.equal(list[0].name, 'Listed'); + assert.equal(list[0].parentArticleId, null); + }); + + it('rejects a category of another company', async () => { + await assert.rejects( + () => store.category.getCategoryArticles(tenant.googleAuthId, other.rootCategoryId), + /Not authorized|not found/, + ); + }); + }); + + describe('ensureByPath', () => { + it('creates missing path segments and is idempotent', async () => { + const leaf = await store.category.ensureByPath({ + googleAuthId: tenant.googleAuthId, + parentId: tenant.rootCategoryId, + path: 'Flowers | CBN | Indoor', + }); + const again = await store.category.ensureByPath({ + googleAuthId: tenant.googleAuthId, + parentId: tenant.rootCategoryId, + path: ' Flowers|CBN |Indoor ', + }); + assert.equal(again, leaf); + + const node = await findInTree(leaf); + assert.equal(node.name, 'Indoor'); + const mid = await findInTree(node.parentId); + assert.equal(mid.name, 'CBN'); + const top = await findInTree(mid.parentId); + assert.equal(top.name, 'Flowers'); + assert.equal(top.parentId, tenant.rootCategoryId); + }); + + it('reuses existing prefix segments', async () => { + const cbn = await store.category.ensureByPath({ + googleAuthId: tenant.googleAuthId, + parentId: tenant.rootCategoryId, + path: 'Flowers | CBN', + }); + const outdoor = await store.category.ensureByPath({ + googleAuthId: tenant.googleAuthId, + parentId: tenant.rootCategoryId, + path: 'Flowers | CBN | Outdoor', + }); + assert.equal((await findInTree(outdoor)).parentId, cbn); + }); + + it('rejects an empty path', async () => { + await assert.rejects( + () => store.category.ensureByPath({ + googleAuthId: tenant.googleAuthId, + parentId: tenant.rootCategoryId, + path: ' | | ', + }), + { message: 'Invalid category path' }, + ); + }); + }); + + describe('rename', () => { + it('renames and re-slugs', async () => { + const cat = await create('Old Name'); + const res = await store.category.rename({ + googleAuthId: tenant.googleAuthId, + id: cat.id, + name: 'Soft Drinks', + }); + assert.deepEqual(res, { id: cat.id, name: 'Soft Drinks', slug: 'soft-drinks' }); + assert.equal((await findInTree(cat.id)).name, 'Soft Drinks'); + }); + + it('rejects an empty name', async () => { + const cat = await create('Keep Name'); + await assert.rejects( + () => store.category.rename({ googleAuthId: tenant.googleAuthId, id: cat.id, name: ' ' }), + { message: 'Name required' }, + ); + }); + + it('rejects an unknown category', async () => { + await assert.rejects( + () => store.category.rename({ googleAuthId: tenant.googleAuthId, id: 999999, name: 'X' }), + /not found/, + ); + }); + }); + + describe('move', () => { + it('moves a category under a new parent', async () => { + const a = await create('Move A'); + const b = await create('Move B'); + const res = await store.category.move({ googleAuthId: tenant.googleAuthId, id: b.id, parentId: a.id }); + assert.equal(res.id, b.id); + assert.equal(res.parentId, a.id); + assert.equal((await findInTree(b.id)).parentId, a.id); + }); + + it('rejects moving a category into itself', async () => { + const a = await create('Self Move'); + await assert.rejects( + () => store.category.move({ googleAuthId: tenant.googleAuthId, id: a.id, parentId: a.id }), + { message: 'Invalid parent' }, + ); + }); + + it('rejects cycles (moving a parent under its descendant)', async () => { + const top = await create('Cycle Top'); + const mid = await create('Cycle Mid', top.id); + const low = await create('Cycle Low', mid.id); + await assert.rejects( + () => store.category.move({ googleAuthId: tenant.googleAuthId, id: top.id, parentId: low.id }), + { message: 'Invalid parent' }, + ); + }); + + it('rejects cross-company moves', async () => { + const a = await create('Cross Move'); + await assert.rejects( + () => store.category.move({ googleAuthId: tenant.googleAuthId, id: a.id, parentId: other.rootCategoryId }), + /Not authorized|Parent not found/, + ); + }); + }); + + describe('order', () => { + it('applies the given sibling order', async () => { + const parent = await create('Order Parent'); + const c1 = await create('O1', parent.id); + const c2 = await create('O2', parent.id); + const c3 = await create('O3', parent.id); + + const res = await store.category.order({ + googleAuthId: tenant.googleAuthId, + parentId: parent.id, + categoryIds: [c3.id, c1.id, c2.id], + }); + assert.deepEqual(res.categoryIds, [c3.id, c1.id, c2.id]); + + assert.equal((await findInTree(c3.id)).sort, 0); + assert.equal((await findInTree(c1.id)).sort, 1); + assert.equal((await findInTree(c2.id)).sort, 2); + }); + + it('rejects incomplete, duplicate or foreign ids', async () => { + const parent = await create('Order Invalid'); + const c1 = await create('I1', parent.id); + const c2 = await create('I2', parent.id); + const stranger = await create('Stranger'); + const call = (categoryIds) => store.category.order({ + googleAuthId: tenant.googleAuthId, + parentId: parent.id, + categoryIds, + }); + + const err = { message: 'Invalid category order' }; + await assert.rejects(() => call('nope'), err); + await assert.rejects(() => call([c1.id]), err); + await assert.rejects(() => call([c1.id, c1.id]), err); + await assert.rejects(() => call([c1.id, stranger.id]), err); + // valid order still works afterwards + await call([c2.id, c1.id]); + }); + }); + + describe('delete', () => { + it('deletes an empty category', async () => { + const cat = await create('Delete Me'); + await store.category.delete({ googleAuthId: tenant.googleAuthId, id: cat.id }); + assert.equal(await findInTree(cat.id), undefined); + }); + + it('refuses to delete the root category', async () => { + await assert.rejects( + () => store.category.delete({ googleAuthId: tenant.googleAuthId, id: tenant.rootCategoryId }), + { message: 'Cannot delete root category' }, + ); + }); + + it('refuses non-recursive delete with subcategories or articles', async () => { + const withSub = await create('Has Sub'); + await create('Sub', withSub.id); + await assert.rejects( + () => store.category.delete({ googleAuthId: tenant.googleAuthId, id: withSub.id }), + { message: 'Category has subcategories' }, + ); + + const withArt = await create('Has Article'); + await createArticle(store, tenant, { parentId: withArt.id }); + await assert.rejects( + () => store.category.delete({ googleAuthId: tenant.googleAuthId, id: withArt.id }), + { message: 'Category has articles' }, + ); + }); + + it('recursive delete removes subtree and articles only living in it', async () => { + const top = await create('Recursive Top'); + const sub = await create('Recursive Sub', top.id); + const keepCat = await create('Recursive Keep'); + + const onlyInSubtree = await createArticle(store, tenant, { parentId: sub.id, name: 'Doomed' }); + const shared = await createArticle(store, tenant, { parentId: sub.id, name: 'Survivor' }); + await store.article.setCategoryIdsBySku(tenant.googleAuthId, shared.sku, [sub.id, keepCat.id]); + + await store.category.delete({ googleAuthId: tenant.googleAuthId, id: top.id, recursive: true }); + + assert.equal(await findInTree(top.id), undefined); + assert.equal(await findInTree(sub.id), undefined); + + await assert.rejects( + () => store.article.getBySku(tenant.googleAuthId, onlyInSubtree.sku), + { message: 'Article not found' }, + ); + const survivor = await store.article.getBySku(tenant.googleAuthId, shared.sku); + assert.deepEqual(survivor.article.categoryIds, [keepCat.id]); + }); + }); + + describe('addImage / deleteImage', () => { + it('adds an image, exposes it in the tree and deletes it again', async () => { + const cat = await create('Image Cat'); + const img = await store.category.addImage(tenant.googleAuthId, cat.id, await makePng()); + + assert.equal(img.id, cat.id); + assert.equal(typeof img.fullHash, 'string'); + assert.ok(img.fullHash.length > 0); + assert.ok(img.thumbW > 0 && img.thumbH > 0); + assert.ok(img.smallThumbW > 0 && img.smallThumbH > 0); + assert.equal((await findInTree(cat.id)).fullHash, img.fullHash); + + const cleared = await store.category.deleteImage(tenant.googleAuthId, cat.id); + assert.deepEqual(cleared, { id: cat.id, thumbW: null, thumbH: null, thumbHash: null }); + assert.equal((await findInTree(cat.id)).fullHash, null); + }); + + it('deleteImage without image throws', async () => { + const cat = await create('No Image Cat'); + await assert.rejects( + () => store.category.deleteImage(tenant.googleAuthId, cat.id), + { message: 'Image not found' }, + ); + }); + }); +}); diff --git a/test/delivery.test.js b/test/delivery.test.js new file mode 100644 index 0000000..070e351 --- /dev/null +++ b/test/delivery.test.js @@ -0,0 +1,303 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestStore, createTenant, createArticle, isoNow } from './helpers.js'; + +describe('store.delivery', () => { + let ctx; + let store; + let tenant; + let other; + let gid; + let supplier; + let warehouse; + let tracked; + + const line = (sku, amount = 5, extra = {}) => ({ sku, name: `Line ${sku}`, pu: 1, amount, net: 10, taxrate: 19, ...extra }); + const create = (body = {}) => + store.delivery.create(gid, { supplierId: supplier.id, date: isoNow(), lines: [], ...body }); + + before(async () => { + ctx = await createTestStore('dash-delivery-'); + store = ctx.store; + tenant = await createTenant(store, { name: 'Delivery Test GmbH' }); + other = await createTenant(store, { name: 'Delivery Other GmbH' }); + gid = tenant.googleAuthId; + supplier = await store.supplier.create(gid, { name: 'Supplier One' }); + warehouse = await store.warehouse.create(gid, { name: 'Hauptlager' }); + tracked = await createArticle(store, tenant, { name: 'Tracked', sku: 'DEL-TRACKED', track_stock: true }); + }); + + after(async () => { + await ctx.cleanup(); + }); + + describe('create / getById', () => { + it('creates a delivery with number, lines and an unbooked summary', async () => { + const d = await create({ + externalNumber: ' EXT-1 ', + lines: [line('DEL-TRACKED', 5), { name: 'Free text', amount: 2, net: 3.5, taxrate: 7 }], + }); + + assert.equal(typeof d.id, 'number'); + assert.equal(d.type, 'delivery'); + assert.ok(d.number); + assert.equal(d.externalNumber, 'EXT-1'); + assert.equal(d.supplierId, supplier.id); + assert.equal(d.lines.length, 2); + assert.equal(d.lines[0].sku, 'DEL-TRACKED'); + assert.equal(d.lines[0].amount, 5); + assert.equal(d.lines[1].sku, null); + assert.equal(d.lines[1].name, 'Free text'); + assert.equal(d.lines[1].pu, 1); + assert.equal(d.positions, 2); + assert.equal(d.isBooked, false); + assert.deepEqual(d.files, []); + + const fetched = await store.delivery.getById(gid, d.id); + assert.equal(fetched.id, d.id); + assert.equal(fetched.number, d.number); + assert.equal(fetched.lines.length, 2); + }); + + it('allocates distinct sequential numbers', async () => { + const a = await create(); + const b = await create(); + assert.notEqual(a.number, b.number); + }); + + it('allows a delivery without supplier', async () => { + const d = await store.delivery.create(gid, { date: isoNow(), lines: [] }); + assert.equal(d.supplierId, null); + }); + + it('validates input', async () => { + await assert.rejects(() => create({ date: 'garbage' }), { message: 'Invalid delivery' }); + await assert.rejects(() => create({ createdAt: 'garbage' }), { message: 'Invalid delivery' }); + await assert.rejects(() => create({ supplierId: '1' }), { message: 'Invalid supplierId' }); + await assert.rejects(() => create({ supplierId: 999999 }), { message: 'Supplier not found' }); + await assert.rejects(() => create({ lines: 'x' }), { message: 'Invalid lines' }); + await assert.rejects(() => create({ lines: [{ amount: 1, net: 1, taxrate: 19 }] }), { message: 'Invalid lines' }); + await assert.rejects(() => create({ lines: [line('DEL-TRACKED', 'many')] }), { message: 'Invalid lines' }); + await assert.rejects(() => create({ lines: [line('DEL-TRACKED', 1, { taxrate: -1 })] }), { message: 'Invalid lines' }); + }); + + it('rejects a supplier of another company', async () => { + const foreign = await store.supplier.create(other.googleAuthId, { name: 'Foreign Supplier' }); + await assert.rejects(() => create({ supplierId: foreign.id }), { message: 'Supplier not found' }); + }); + + it('rejects booking a parent article on a document', async () => { + const attr = await store.attributename.create(gid, { name: 'Delivery Attr' }); + const parent = await createArticle(store, tenant, { name: 'Parent', sku: 'DEL-PARENT' }); + await store.article.toggleParent(gid, parent.sku, { isParent: true, attributeNameId: attr.id }); + await assert.rejects( + () => create({ lines: [line('DEL-PARENT')] }), + /A parent article cannot be booked on documents \(DEL-PARENT\)/, + ); + }); + + it('getById rejects unknown and foreign deliveries', async () => { + const d = await create(); + await assert.rejects(() => store.delivery.getById(gid, 999999), { message: 'Delivery not found' }); + await assert.rejects(() => store.delivery.getById(other.googleAuthId, d.id), { message: 'Delivery not found' }); + }); + }); + + describe('update', () => { + it('updates header fields', async () => { + const d = await create({ externalNumber: 'OLD' }); + const upd = await store.delivery.update(gid, d.id, { externalNumber: 'NEW', number: 'MY-NUMBER' }); + assert.equal(upd.externalNumber, 'NEW'); + assert.equal(upd.number, 'MY-NUMBER'); + + const cleared = await store.delivery.update(gid, d.id, { externalNumber: ' ' }); + assert.equal(cleared.externalNumber, null); + + const noSupplier = await store.delivery.update(gid, d.id, { supplierId: null }); + assert.equal(noSupplier.supplierId, null); + }); + + it('replaces lines of an unbooked delivery', async () => { + const d = await create({ lines: [line('DEL-TRACKED', 1)] }); + const upd = await store.delivery.update(gid, d.id, { lines: [line('DEL-TRACKED', 7), line('DEL-TRACKED', 3)] }); + assert.deepEqual(upd.lines.map((l) => l.amount), [7, 3]); + }); + + it('refuses to replace lines that are already booked', async () => { + const d = await create({ lines: [line('DEL-TRACKED', 4)] }); + await store.delivery.book(gid, { id: d.id, warehouseId: warehouse.id }); + await assert.rejects( + () => store.delivery.update(gid, d.id, { lines: [line('DEL-TRACKED', 9)] }), + { message: 'Delivery lines already booked on ledger' }, + ); + assert.equal((await store.delivery.getById(gid, d.id)).lines[0].amount, 4); + }); + + it('validates input', async () => { + const d = await create(); + await assert.rejects(() => store.delivery.update(gid, d.id, {}), { message: 'Invalid delivery' }); + await assert.rejects(() => store.delivery.update(gid, d.id, { number: ' ' }), { message: 'Invalid delivery' }); + await assert.rejects(() => store.delivery.update(gid, d.id, { date: 'x' }), { message: 'Invalid delivery' }); + await assert.rejects(() => store.delivery.update(gid, d.id, { createdAt: 'x' }), { message: 'Invalid delivery' }); + await assert.rejects(() => store.delivery.update(gid, d.id, { supplierId: 999999 }), { message: 'Supplier not found' }); + await assert.rejects(() => store.delivery.update(gid, 999999, { number: 'X' }), { message: 'Delivery not found' }); + await assert.rejects(() => store.delivery.update(gid, Number.NaN, { number: 'X' }), { message: 'Delivery not found' }); + }); + + it('cannot update deliveries of another company', async () => { + const d = await create(); + await assert.rejects( + () => store.delivery.update(other.googleAuthId, d.id, { number: 'HIJACK' }), + { message: 'Delivery not found' }, + ); + }); + }); + + describe('book', () => { + it('books all lines and marks the delivery as booked', async () => { + const d = await create({ lines: [line('DEL-TRACKED', 6), line('DEL-TRACKED', 2)] }); + const booked = await store.delivery.book(gid, { id: d.id, warehouseId: warehouse.id }); + + assert.equal(booked.isBooked, true); + assert.equal(booked.ledger.isBooked, true); + assert.equal(booked.ledger.completelyOnLedgerPositions, 2); + + for (const l of booked.lines) { + const entries = await store.ledger.listByManifestarticleId(gid, l.id); + assert.deepEqual(entries.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE']); + assert.equal(entries[1].warehouse_id, warehouse.id); + } + }); + + it('accepts a plain id and rejects double booking', async () => { + const d = await create({ lines: [line('DEL-TRACKED', 1)] }); + await store.delivery.book(gid, d.id); + await assert.rejects( + () => store.delivery.book(gid, d.id), + /Delivery line(s)? already (accepted|booked) on ledger/, + ); + }); + + it('rejects invalid, unknown and empty deliveries', async () => { + await assert.rejects(() => store.delivery.book(gid, {}), { message: 'Delivery not found' }); + await assert.rejects(() => store.delivery.book(gid, 999999), { message: 'Delivery not found' }); + const empty = await create(); + await assert.rejects(() => store.delivery.book(gid, empty.id), { message: 'Delivery has no lines' }); + }); + + it('cannot book a delivery of another company', async () => { + const d = await create({ lines: [line('DEL-TRACKED', 1)] }); + await assert.rejects(() => store.delivery.book(other.googleAuthId, d.id), { message: 'Delivery not found' }); + }); + }); + + describe('delete', () => { + it('deletes an unbooked delivery', async () => { + const d = await create({ lines: [line('DEL-TRACKED', 1)] }); + await store.delivery.delete(gid, d.id); + await assert.rejects(() => store.delivery.getById(gid, d.id), { message: 'Delivery not found' }); + }); + + it('refuses to delete a booked delivery', async () => { + const d = await create({ lines: [line('DEL-TRACKED', 1)] }); + await store.delivery.book(gid, d.id); + await assert.rejects(() => store.delivery.delete(gid, d.id), { message: 'Delivery in use in ledger' }); + assert.ok(await store.delivery.getById(gid, d.id)); + }); + + it('rejects unknown ids and foreign deliveries', async () => { + const d = await create(); + await assert.rejects(() => store.delivery.delete(gid, 999999), { message: 'Delivery not found' }); + await assert.rejects(() => store.delivery.delete(gid, Number.NaN), { message: 'Delivery not found' }); + await assert.rejects(() => store.delivery.delete(other.googleAuthId, d.id), { message: 'Delivery not found' }); + assert.ok(await store.delivery.getById(gid, d.id)); + }); + }); + + describe('list', () => { + let lt; // isolated tenant so counts are exact + let ls1; + let ls2; + let d1; + let d2; + let d3; + + before(async () => { + lt = await createTenant(store, { name: 'Delivery List GmbH' }); + await store.warehouse.create(lt.googleAuthId, { name: 'List WH' }); + ls1 = await store.supplier.create(lt.googleAuthId, { name: 'Alpha Trading' }); + ls2 = await store.supplier.create(lt.googleAuthId, { name: 'Beta Supplies' }); + await createArticle(store, lt, { sku: 'LST-1', track_stock: true }); + + const mk = (body) => store.delivery.create(lt.googleAuthId, { lines: [line('LST-1', 2)], ...body }); + d1 = await mk({ supplierId: ls1.id, externalNumber: 'EXT-ALPHA', date: '2026-01-10T00:00:00Z' }); + d2 = await mk({ supplierId: ls2.id, externalNumber: 'EXT-BETA', date: '2026-02-10T00:00:00Z' }); + d3 = await mk({ supplierId: ls2.id, externalNumber: 'EXT-BETA-2', date: '2026-03-10T00:00:00Z' }); + await store.delivery.book(lt.googleAuthId, d1.id); + }); + + it('returns a plain array without options, newest date first', async () => { + const all = await store.delivery.list(lt.googleAuthId); + assert.ok(Array.isArray(all)); + assert.deepEqual(all.map((d) => d.id), [d3.id, d2.id, d1.id]); + assert.equal(all[2].isBooked, true); + assert.equal(all[0].isBooked, false); + }); + + it('paginates', async () => { + const page = await store.delivery.list(lt.googleAuthId, { offset: 1, limit: 1 }); + assert.equal(page.total, 3); + assert.equal(page.unfilteredTotal, 3); + assert.equal(page.offset, 1); + assert.equal(page.limit, 1); + assert.deepEqual(page.deliveries.map((d) => d.id), [d2.id]); + }); + + it('filters by search, supplier and unbooked state', async () => { + const bySearch = await store.delivery.list(lt.googleAuthId, { search: 'EXT-BETA' }); + assert.equal(bySearch.total, 2); + assert.equal(bySearch.unfilteredTotal, 3); + + const bySupplierName = await store.delivery.list(lt.googleAuthId, { search: 'Alpha' }); + assert.deepEqual(bySupplierName.deliveries.map((d) => d.id), [d1.id]); + + const bySupplier = await store.delivery.list(lt.googleAuthId, { supplierId: ls1.id }); + assert.deepEqual(bySupplier.deliveries.map((d) => d.id), [d1.id]); + + const unbooked = await store.delivery.list(lt.googleAuthId, { onlyUnbooked: true }); + assert.deepEqual(unbooked.deliveries.map((d) => d.id).sort(), [d2.id, d3.id].sort()); + }); + + it('validates options', async () => { + const l = (o) => store.delivery.list(lt.googleAuthId, o); + await assert.rejects(() => l({ offset: -1 }), { message: 'Invalid offset' }); + await assert.rejects(() => l({ limit: 0 }), { message: 'Invalid limit' }); + await assert.rejects(() => l({ seekMonths: '1' }), { message: 'Invalid seekMonths' }); + await assert.rejects(() => l({ supplierId: 0 }), { message: 'Invalid supplierId' }); + }); + + it('does not leak deliveries of other companies', async () => { + const mine = await store.delivery.list(gid); + assert.ok(!mine.some((d) => [d1.id, d2.id, d3.id].includes(d.id))); + }); + }); + + describe('rebuildSummaries', () => { + it('keeps list results stable after a rebuild', async () => { + const rt = await createTenant(store, { name: 'Delivery Rebuild GmbH' }); + await store.warehouse.create(rt.googleAuthId, { name: 'Rebuild WH' }); + await createArticle(store, rt, { sku: 'RB-1', track_stock: true }); + const d = await store.delivery.create(rt.googleAuthId, { date: isoNow(), lines: [line('RB-1', 3)] }); + await store.delivery.book(rt.googleAuthId, d.id); + + const before = await store.delivery.list(rt.googleAuthId); + await store.delivery.rebuildSummaries(rt.googleAuthId); + const after = await store.delivery.list(rt.googleAuthId); + + assert.equal(after.length, before.length); + assert.equal(after[0].isBooked, true); + assert.equal(after[0].positions, before[0].positions); + assert.equal(after[0].bookedQuantity, before[0].bookedQuantity); + }); + }); +}); diff --git a/test/helpers.js b/test/helpers.js new file mode 100644 index 0000000..4d580c5 --- /dev/null +++ b/test/helpers.js @@ -0,0 +1,89 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import sharp from 'sharp'; +import LibDashStore from '../src/index.js'; +import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; + +/** + * Creates an isolated sqlite-backed store in a temp directory. + * Call `cleanup()` in an `after()` hook. + */ +export async function createTestStore(prefix = 'dash-unit-') { + const tempDir = mkdtempSync(join(tmpdir(), prefix)); + const provider = new BetterSqlite3Provider({ + filename: join(tempDir, 'test.db'), + storageDir: join(tempDir, 'storage'), + }); + await provider.initialize(); + const store = await new LibDashStore(provider); + + const cleanup = async () => { + try { + provider.db?.close?.(); + } catch { + // ignore + } + rmSync(tempDir, { recursive: true, force: true }); + }; + + return { store, provider, tempDir, cleanup }; +} + +let tenantSeq = 0; + +/** + * Creates a google_auth user + company and selects it. + * Returns everything a test typically needs. + */ +export async function createTenant(store, { name, email } = {}) { + tenantSeq += 1; + const googleAuthId = await store.google_auth.add(email ?? `tenant-${tenantSeq}@example.com`); + const company = await store.company.create({ + name: name ?? `Tenant ${tenantSeq} GmbH`, + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + return { googleAuthId, company, rootCategoryId: company.rootCategoryId }; +} + +let articleSeq = 0; + +/** Creates an article under the tenant's root category (or `parentId`). */ +export async function createArticle(store, tenant, overrides = {}) { + articleSeq += 1; + return store.article.createUnderParent({ + googleAuthId: tenant.googleAuthId, + parentId: overrides.parentId ?? tenant.rootCategoryId, + name: overrides.name ?? `Article ${articleSeq}`, + ...overrides, + }); +} + +/** Returns the first customer group id of the tenant (needed for entity.create). */ +export async function firstCustomerGroupId(store, tenant) { + const groups = await store.customergroup.list(tenant.googleAuthId); + return groups[0].id; +} + +/** Creates a customer entity. */ +export async function createCustomer(store, tenant, name = 'Customer') { + return store.entity.create(tenant.googleAuthId, { + customergroupId: await firstCustomerGroupId(store, tenant), + name, + }); +} + +/** Generates a small solid-color PNG buffer for image tests. */ +export async function makePng({ width = 64, height = 48, r = 200, g = 30, b = 60 } = {}) { + return sharp({ + create: { width, height, channels: 3, background: { r, g, b } }, + }) + .png() + .toBuffer(); +} + +export const isoNow = () => new Date().toISOString(); diff --git a/test/ledger.test.js b/test/ledger.test.js new file mode 100644 index 0000000..5d2416c --- /dev/null +++ b/test/ledger.test.js @@ -0,0 +1,446 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { createTestStore, createTenant, createArticle, createCustomer, isoNow } from './helpers.js'; +import { computePrevHash, INIT_HASH } from '../src/store/ledger/add.js'; + +describe('store.ledger', () => { + let ctx; + let store; + let tenant; + let other; + let gid; + let supplier; + let warehouse; + let warehouse2; + let customer; + let seq = 0; + + const uniqueSku = (p) => `${p}-${++seq}`; + + const makeDelivery = async (sku, amount = 5, extra = {}) => { + const d = await store.delivery.create(gid, { + supplierId: supplier.id, + date: isoNow(), + lines: [{ sku, name: `Line ${sku}`, pu: 1, amount, net: 10, taxrate: 19, ...extra }], + }); + return { delivery: d, lineId: d.lines[0].id }; + }; + + const makeShipmentLine = async (sku, orderAmount, shipAmount) => { + seq += 1; + const order = await store.order.create(gid, { + number: `LG-O-${seq}`, + date: '2026-10-12', + entityId: customer.id, + lines: [{ sku, name: `Order ${sku}`, pu: 1, amount: orderAmount, net: 25, taxrate: 19 }], + }); + const shipment = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku, amount: shipAmount }] }); + return { order, shipment, lineId: shipment.lines[0].id }; + }; + + const stockOf = async (sku, warehouseId = warehouse.id) => { + const art = (await store.article.getBySku(gid, sku)).article; + return art.warehouses.find((w) => w.id === warehouseId)?.stock; + }; + + before(async () => { + ctx = await createTestStore('dash-ledger-'); + store = ctx.store; + tenant = await createTenant(store, { name: 'Ledger Test GmbH' }); + other = await createTenant(store, { name: 'Ledger Other GmbH' }); + gid = tenant.googleAuthId; + supplier = await store.supplier.create(gid, { name: 'Ledger Supplier' }); + warehouse = await store.warehouse.create(gid, { name: 'Hauptlager' }); + warehouse2 = await store.warehouse.create(gid, { name: 'Nebenlager' }); + customer = await createCustomer(store, tenant, 'Ledger Customer'); + }); + + after(async () => { + await ctx.cleanup(); + }); + + describe('add — delivery lines', () => { + it('untracked article: single ACCEPTANCE row without warehouse', async () => { + const sku = uniqueSku('UNTRACKED'); + await createArticle(store, tenant, { sku }); + const { delivery, lineId } = await makeDelivery(sku, 5); + + const row = await store.ledger.add(gid, { + manifestarticle_id: lineId, + transaction_type: 'ACCEPTANCE', + amount: 5, + transaction_text: `Lieferung ${delivery.number}`, + }); + + assert.equal(typeof row.id, 'number'); + assert.equal(row.manifestarticle_id, lineId); + assert.equal(row.warehouse_id, null); + assert.equal(row.transaction_type, 'ACCEPTANCE'); + assert.equal(row.amount, 5); + assert.equal(row.transaction_text, `Lieferung ${delivery.number}`); + assert.equal(row.hash_version, 1); + assert.match(row.prev_hash, /^[0-9a-f]{128}$|^TODO_TSE_BASED_INITHASH$/); + assert.ok(!Number.isNaN(Date.parse(row.created_at))); + + const entries = await store.ledger.listByManifestarticleId(gid, lineId); + assert.equal(entries.length, 1); + }); + + it('tracked article: ACCEPTANCE + WAREHOUSE rows and stock increase', async () => { + const sku = uniqueSku('TRACKED'); + await createArticle(store, tenant, { sku, track_stock: true }); + const { lineId } = await makeDelivery(sku, 10); + + const row = await store.ledger.add(gid, { + manifestarticle_id: lineId, + warehouse_id: warehouse.id, + transaction_type: 'ACCEPTANCE', + amount: 10, + transaction_text: 'Eingang', + }); + assert.equal(row.warehouse_id, null); + assert.equal(row.transaction_type, 'ACCEPTANCE'); + + const entries = await store.ledger.listByManifestarticleId(gid, lineId); + assert.deepEqual(entries.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE']); + assert.deepEqual(entries.map((e) => e.warehouse_id), [null, warehouse.id]); + assert.deepEqual(entries.map((e) => e.amount), [10, 10]); + assert.equal(entries[1].id, entries[0].id + 1); + assert.equal(await stockOf(sku), 10); + }); + + it('uses the default warehouse when none is given', async () => { + const sku = uniqueSku('DEFWH'); + await createArticle(store, tenant, { sku, track_stock: true }); + const { lineId } = await makeDelivery(sku, 3); + await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 3, transaction_text: 'x' }); + + const entries = await store.ledger.listByManifestarticleId(gid, lineId); + assert.equal(entries.length, 2); + assert.ok(entries[1].warehouse_id > 0); + }); + + it('stores acceptance amounts as positive numbers', async () => { + const sku = uniqueSku('ABS'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeDelivery(sku, 4); + const row = await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: -4, transaction_text: 'x' }); + assert.equal(row.amount, 4); + }); + + it('rejects double booking of a delivery line', async () => { + const sku = uniqueSku('DOUBLE'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeDelivery(sku, 2); + const input = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'x' }; + await store.ledger.add(gid, input); + await assert.rejects(() => store.ledger.add(gid, input), { message: 'Delivery line already booked on ledger' }); + assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 1); + }); + + it('rejects an unknown warehouse and rolls back', async () => { + const sku = uniqueSku('BADWH'); + await createArticle(store, tenant, { sku, track_stock: true }); + const { lineId } = await makeDelivery(sku, 2); + await assert.rejects( + () => store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: 999999, transaction_type: 'ACCEPTANCE', amount: 2, transaction_text: 'x' }), + { message: 'Warehouse not found' }, + ); + assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 0, 'transaction rolled back'); + }); + + it('accepts camelCase aliases', async () => { + const sku = uniqueSku('CAMEL'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeDelivery(sku, 1); + const row = await store.ledger.add(gid, { manifestarticleId: lineId, transactionType: 'ACCEPTANCE', amount: 1, transactionText: 'alias' }); + assert.equal(row.manifestarticle_id, lineId); + assert.equal(row.transaction_text, 'alias'); + }); + }); + + describe('add — shipment lines', () => { + it('books SHIPMENT (negative) + WAREHOUSE rows and reduces stock', async () => { + const sku = uniqueSku('SHIP'); + await createArticle(store, tenant, { sku, track_stock: true }); + const { lineId: inLine } = await makeDelivery(sku, 10); + await store.ledger.add(gid, { manifestarticle_id: inLine, warehouse_id: warehouse.id, transaction_type: 'ACCEPTANCE', amount: 10, transaction_text: 'in' }); + + const { lineId } = await makeShipmentLine(sku, 5, 3); + const row = await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse.id, transaction_type: 'SHIPMENT', amount: 3, transaction_text: 'out' }); + + assert.equal(row.transaction_type, 'SHIPMENT'); + assert.equal(row.warehouse_id, null); + assert.equal(row.amount, -3); + + const entries = await store.ledger.listByManifestarticleId(gid, lineId); + assert.deepEqual(entries.map((e) => [e.transaction_type, e.warehouse_id, e.amount]), [ + ['SHIPMENT', null, -3], + ['WAREHOUSE', warehouse.id, -3], + ]); + assert.equal(await stockOf(sku), 7); + }); + + it('rejects double booking of a shipment line', async () => { + const sku = uniqueSku('SHIPDBL'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeShipmentLine(sku, 2, 2); + const input = { manifestarticle_id: lineId, transaction_type: 'SHIPMENT', amount: 2, transaction_text: 'x' }; + await store.ledger.add(gid, input); + await assert.rejects(() => store.ledger.add(gid, input), { message: 'Shipment line already booked on ledger' }); + }); + }); + + describe('add — guards', () => { + it('refuses to book quote and order lines', async () => { + const sku = uniqueSku('DOC'); + await createArticle(store, tenant, { sku }); + const line = { sku, name: 'x', pu: 1, amount: 1, net: 1, taxrate: 19 }; + const quote = await store.quote.create(gid, { number: `LG-Q-${sku}`, date: isoNow(), entityId: customer.id, lines: [line] }); + const order = await store.order.create(gid, { number: `LG-O-${sku}`, date: isoNow(), entityId: customer.id, lines: [line] }); + const base = { transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' }; + + await assert.rejects(() => store.ledger.add(gid, { ...base, manifestarticle_id: quote.lines[0].id }), { message: 'Cannot book ledger entries for quote' }); + await assert.rejects(() => store.ledger.add(gid, { ...base, manifestarticle_id: order.lines[0].id }), { message: 'Cannot book ledger entries for order' }); + }); + + it('validates the payload', async () => { + const sku = uniqueSku('VALID'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeDelivery(sku, 1); + const ok = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' }; + const bad = (patch) => store.ledger.add(gid, { ...ok, ...patch }); + const err = { message: 'Invalid ledger entry' }; + + await assert.rejects(() => bad({ manifestarticle_id: 0 }), err); + await assert.rejects(() => bad({ manifestarticle_id: 1.5 }), err); + await assert.rejects(() => bad({ manifestarticle_id: 'x' }), err); + await assert.rejects(() => bad({ warehouse_id: -1 }), err); + await assert.rejects(() => bad({ warehouse_id: 'x' }), err); + await assert.rejects(() => bad({ transaction_type: ' ' }), err); + await assert.rejects(() => bad({ transaction_type: 'T'.repeat(101) }), err); + await assert.rejects(() => bad({ transaction_type: 5 }), err); + await assert.rejects(() => bad({ amount: 'abc' }), err); + await assert.rejects(() => bad({ amount: Number.POSITIVE_INFINITY }), err); + await assert.rejects(() => bad({ transaction_text: undefined }), err); + await assert.rejects(() => bad({ transaction_text: null }), err); + }); + + it('rejects unknown manifest articles and ones of other companies', async () => { + const sku = uniqueSku('FOREIGN'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeDelivery(sku, 1); + const input = { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' }; + + await assert.rejects(() => store.ledger.add(gid, { ...input, manifestarticle_id: 999999 }), { message: 'Manifest article not found' }); + await assert.rejects(() => store.ledger.add(other.googleAuthId, input), { message: 'Manifest article not found' }); + assert.equal((await store.ledger.listByManifestarticleId(gid, lineId)).length, 0); + }); + }); + + describe('hash chain', () => { + it('chains every row to the SHA-512 hash of the previous row', async () => { + const t = await createTenant(store, { name: 'Chain GmbH' }); + await store.warehouse.create(t.googleAuthId, { name: 'Chain WH' }); + await createArticle(store, t, { sku: 'CHAIN-1', track_stock: true }); + const rows = []; + for (let i = 1; i <= 3; i += 1) { + const d = await store.delivery.create(t.googleAuthId, { + date: isoNow(), + lines: [{ sku: 'CHAIN-1', name: 'c', pu: 1, amount: i, net: 1, taxrate: 19 }], + }); + await store.ledger.add(t.googleAuthId, { manifestarticle_id: d.lines[0].id, transaction_type: 'ACCEPTANCE', amount: i, transaction_text: `c${i}` }); + rows.push(...(await store.ledger.listByManifestarticleId(t.googleAuthId, d.lines[0].id))); + } + + assert.equal(rows.length, 6); + for (let i = 1; i < rows.length; i += 1) { + assert.equal(rows[i].prev_hash, computePrevHash(rows[i - 1]), `row ${rows[i].id} links to row ${rows[i - 1].id}`); + assert.ok(rows[i].id > rows[i - 1].id); + assert.equal(rows[i].hash_version, 1); + } + }); + + it('computePrevHash returns INIT_HASH for no row and a deterministic sha512 otherwise', () => { + assert.equal(computePrevHash(null), INIT_HASH); + assert.equal(computePrevHash(undefined), INIT_HASH); + + const row = { + id: 7, manifestarticle_id: 3, warehouse_id: undefined, transaction_type: 'ACCEPTANCE', + amount: 2, transaction_text: 'x', hash_version: 1, prev_hash: INIT_HASH, + created_at: new Date('2026-01-01T00:00:00.000Z'), + }; + const expected = createHash('sha512').update(JSON.stringify({ + id: 7, manifestarticle_id: 3, warehouse_id: null, transaction_type: 'ACCEPTANCE', + amount: 2, transaction_text: 'x', hash_version: 1, prev_hash: INIT_HASH, + created_at: '2026-01-01T00:00:00.000Z', + }), 'utf8').digest('hex'); + assert.equal(computePrevHash(row), expected); + assert.equal(computePrevHash({ ...row, created_at: '2026-01-01T00:00:00.000Z' }), expected); + assert.notEqual(computePrevHash({ ...row, amount: 3 }), expected); + }); + }); + + describe('listByManifestarticleId', () => { + it('filters by transaction type and warehouse', async () => { + const sku = uniqueSku('FILTER'); + await createArticle(store, tenant, { sku, track_stock: true }); + const { lineId } = await makeDelivery(sku, 4); + await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse2.id, transaction_type: 'ACCEPTANCE', amount: 4, transaction_text: 'x' }); + + const byType = await store.ledger.listByManifestarticleId(gid, lineId, 'WAREHOUSE'); + assert.deepEqual(byType.map((e) => e.transaction_type), ['WAREHOUSE']); + + const byObjType = await store.ledger.listByManifestarticleId(gid, lineId, { transactionType: 'ACCEPTANCE' }); + assert.deepEqual(byObjType.map((e) => e.transaction_type), ['ACCEPTANCE']); + + const byWh = await store.ledger.listByManifestarticleId(gid, lineId, { warehouse_id: warehouse2.id }); + assert.deepEqual(byWh.map((e) => e.warehouse_id), [warehouse2.id]); + + const none = await store.ledger.listByManifestarticleId(gid, lineId, { warehouseId: warehouse.id }); + assert.deepEqual(none, []); + assert.ok(byType[0].sku === sku); + }); + + it('rejects invalid, unknown and foreign lines', async () => { + const sku = uniqueSku('LISTERR'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeDelivery(sku, 1); + await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 0), { message: 'Invalid manifest article id' }); + await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 'x'), { message: 'Invalid manifest article id' }); + await assert.rejects(() => store.ledger.listByManifestarticleId(gid, 999999), { message: 'Manifest article not found' }); + await assert.rejects(() => store.ledger.listByManifestarticleId(other.googleAuthId, lineId), { message: 'Manifest article not found' }); + }); + }); + + describe('listBySku', () => { + it('lists all movements of an article in booking order', async () => { + const sku = uniqueSku('BYSKU'); + await createArticle(store, tenant, { sku, track_stock: true }); + const { lineId } = await makeDelivery(sku, 8); + await store.ledger.add(gid, { manifestarticle_id: lineId, warehouse_id: warehouse.id, transaction_type: 'ACCEPTANCE', amount: 8, transaction_text: 'in' }); + const ship = await makeShipmentLine(sku, 5, 2); + await store.ledger.add(gid, { manifestarticle_id: ship.lineId, warehouse_id: warehouse.id, transaction_type: 'SHIPMENT', amount: 2, transaction_text: 'out' }); + + const all = await store.ledger.listBySku(gid, sku); + assert.deepEqual(all.map((e) => e.transaction_type), ['ACCEPTANCE', 'WAREHOUSE', 'SHIPMENT', 'WAREHOUSE']); + assert.ok(all.every((e) => e.sku === sku)); + assert.equal(all.reduce((sum, e) => sum + (e.warehouse_id ? e.amount : 0), 0), 6, 'warehouse rows sum to stock'); + + const shipments = await store.ledger.listBySku(gid, sku, 'SHIPMENT'); + assert.equal(shipments.length, 1); + assert.equal(shipments[0].amount, -2); + + const whRows = await store.ledger.listBySku(gid, sku, { warehouseId: warehouse.id }); + assert.equal(whRows.length, 2); + }); + + it('trims the SKU, rejects blanks and does not leak across companies', async () => { + const sku = uniqueSku('BYSKU2'); + await createArticle(store, tenant, { sku }); + const { lineId } = await makeDelivery(sku, 1); + await store.ledger.add(gid, { manifestarticle_id: lineId, transaction_type: 'ACCEPTANCE', amount: 1, transaction_text: 'x' }); + + assert.equal((await store.ledger.listBySku(gid, ` ${sku} `)).length, 1); + assert.deepEqual(await store.ledger.listBySku(other.googleAuthId, sku), []); + await assert.rejects(() => store.ledger.listBySku(gid, ' '), { message: 'Invalid SKU' }); + await assert.rejects(() => store.ledger.listBySku(gid, null), { message: 'Invalid SKU' }); + assert.deepEqual(await store.ledger.listBySku(gid, 'NO-SUCH-SKU'), []); + }); + }); + + describe('stock.adjust (adjustment manifests)', () => { + it('books a warehouse movement via the ledger and updates stock', async () => { + const sku = uniqueSku('ADJ'); + await createArticle(store, tenant, { sku, track_stock: true }); + + const res = await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 12, text: 'Inventur' }); + assert.equal(res.manifest.type, 'adjustment'); + assert.equal(res.ledger.length, 1); + assert.equal(res.ledger[0].warehouse_id, warehouse.id); + assert.equal(res.ledger[0].amount, 12); + assert.equal(await stockOf(sku), 12); + + await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: -5 }); + assert.equal(await stockOf(sku), 7); + assert.equal((await store.ledger.listBySku(gid, sku)).length, 2); + }); + + it('rejects adjustments for untracked articles and bad amounts', async () => { + const sku = uniqueSku('ADJBAD'); + await createArticle(store, tenant, { sku }); + await assert.rejects( + () => store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 1 }), + { message: `Stock tracking is disabled for article (${sku})` }, + ); + const tracked = uniqueSku('ADJBAD2'); + await createArticle(store, tenant, { sku: tracked, track_stock: true }); + await assert.rejects(() => store.stock.adjust(gid, { sku: tracked, warehouseId: warehouse.id, amount: 0 }), /non-zero/); + }); + + it('disabling tracking writes a zeroing ledger entry', async () => { + const sku = uniqueSku('ADJOFF'); + await createArticle(store, tenant, { sku, track_stock: true }); + await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 9 }); + assert.equal(await stockOf(sku), 9); + + await store.article.setTrackStockBySku(gid, sku, false); + assert.equal(await stockOf(sku), 0); + }); + }); + + describe('stock.accept / stock.ship', () => { + it('accept books positive lines as ACCEPTANCE and negative lines as RETURN', async () => { + const sku = uniqueSku('ACCEPT'); + await createArticle(store, tenant, { sku, track_stock: true }); + const d = await store.delivery.create(gid, { + supplierId: supplier.id, + date: isoNow(), + lines: [ + { sku, name: 'in', pu: 1, amount: 6, net: 10, taxrate: 19 }, + { sku, name: 'ret', pu: 1, amount: -2, net: 10, taxrate: 19 }, + ], + }); + + const pos = await store.stock.accept(gid, { manifestarticleId: d.lines[0].id, warehouseId: warehouse.id }); + assert.equal(pos.document_ledger.transaction_type, 'ACCEPTANCE'); + assert.equal(pos.document_ledger.amount, 6); + assert.equal(pos.adjust_ledger.amount, 6); + assert.equal(pos.adjust_ledger.warehouse_id, warehouse.id); + + const neg = await store.stock.accept(gid, { manifestarticleId: d.lines[1].id, warehouseId: warehouse.id }); + assert.equal(neg.document_ledger.transaction_type, 'RETURN'); + assert.equal(neg.document_ledger.amount, -2); + assert.equal(await stockOf(sku), 4); + }); + + it('ship books positive lines as SHIPMENT and negative lines as RETOURE', async () => { + const sku = uniqueSku('SHIPSTK'); + await createArticle(store, tenant, { sku, track_stock: true }); + await store.stock.adjust(gid, { sku, warehouseId: warehouse.id, amount: 20 }); + + seq += 1; + const order = await store.order.create(gid, { + number: `LG-SO-${seq}`, date: '2026-10-12', entityId: customer.id, + lines: [{ sku, name: 'o', pu: 1, amount: 10, net: 1, taxrate: 19 }], + }); + const shipment = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku, amount: 4 }, { sku, amount: -1 }] }); + + const pos = await store.stock.ship(gid, { manifestarticleId: shipment.lines[0].id, warehouseId: warehouse.id }); + assert.equal(pos.document_ledger.transaction_type, 'SHIPMENT'); + assert.equal(pos.document_ledger.amount, -4); + + const neg = await store.stock.ship(gid, { manifestarticleId: shipment.lines[1].id, warehouseId: warehouse.id }); + assert.equal(neg.document_ledger.transaction_type, 'RETOURE'); + assert.equal(neg.document_ledger.amount, 1); + assert.equal(await stockOf(sku), 17); + }); + }); + + describe('append-only', () => { + it('exposes no mutating API besides add', () => { + assert.deepEqual(Object.keys(store.ledger).sort(), ['add', 'listByManifestarticleId', 'listBySku']); + }); + }); +}); diff --git a/test/shipment.test.js b/test/shipment.test.js new file mode 100644 index 0000000..8ec6ee7 --- /dev/null +++ b/test/shipment.test.js @@ -0,0 +1,330 @@ +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { createTestStore, createTenant, createArticle, createCustomer } from './helpers.js'; + +describe('store.shipment', () => { + let ctx; + let store; + let tenant; + let other; + let gid; + let customer; + let seq = 0; + + const makeOrder = async (lines = [ + { sku: 'SH-A', amount: 10, net: 100, taxrate: 19 }, + { sku: 'SH-B', amount: 5, net: 50, taxrate: 19 }, + ], date = '2026-10-12') => { + seq += 1; + return store.order.create(gid, { + number: `AU-${2000 + seq}`, + date, + entityId: customer.id, + lines, + }); + }; + + before(async () => { + ctx = await createTestStore('dash-shipment-'); + store = ctx.store; + tenant = await createTenant(store, { name: 'Shipment Test GmbH' }); + other = await createTenant(store, { name: 'Shipment Other GmbH' }); + gid = tenant.googleAuthId; + customer = await createCustomer(store, tenant, 'Max Mustermann'); + // untracked articles -> no stock prerequisites for shipments + await createArticle(store, tenant, { name: 'Item A', sku: 'SH-A' }); + await createArticle(store, tenant, { name: 'Item B', sku: 'SH-B' }); + }); + + after(async () => { + await ctx.cleanup(); + }); + + describe('create', () => { + it('creates numbered shipments from an order', async () => { + const order = await makeOrder(); + const s1 = await store.shipment.create(gid, { + orderId: order.id, + lines: [{ sku: 'SH-A', amount: 6 }, { sku: 'SH-B', amount: 3 }], + }); + const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] }); + + assert.equal(s1.number, `${order.number}-L-1`); + assert.equal(s2.number, `${order.number}-L-2`); + assert.equal(s1.type, 'shipment'); + assert.equal(s1.state, 'new'); + assert.equal(s1.lineCount, 2); + assert.equal(s1.packageCount, 0); + assert.equal(s1.entityId, customer.id); + assert.deepEqual(s1.lines.map((l) => [l.sku, l.amount]), [['SH-A', 6], ['SH-B', 3]]); + // line data defaults from the order line + assert.equal(s1.lines[0].net, 100); + assert.equal(s1.lines[0].taxrate, 19); + }); + + it('defaults amount to the remaining quantity', async () => { + const order = await makeOrder(); + await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 3 }] }); + const rest = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] }); + assert.equal(rest.lines[0].amount, 7); + }); + + it('ships everything remaining when lines are omitted', async () => { + const order = await makeOrder(); + await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] }); + const rest = await store.shipment.create(gid, { orderId: order.id }); + assert.deepEqual( + rest.lines.map((l) => [l.sku, l.amount]).sort(), + [['SH-A', 6], ['SH-B', 5]], + ); + await assert.rejects(() => store.shipment.create(gid, { orderId: order.id }), { message: 'All items already shipped' }); + }); + + it('updates the order summary', async () => { + const order = await makeOrder(); + const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id); + + assert.equal(summary().shipped_quantity, 0); + await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] }); + assert.equal(summary().shipped_quantity, 10); + assert.equal(summary().is_shipped, 0); + await store.shipment.create(gid, { orderId: order.id }); + assert.equal(summary().shipped_quantity, 15); + assert.equal(summary().is_shipped, 1); + }); + + it('enforces the ordered quantity ceiling', async () => { + const order = await makeOrder(); + await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 6 }] }); + await assert.rejects( + () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 5 }] }), + { message: 'Shipment amount exceeds ordered amount for article (SH-A)' }, + ); + await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 4 }] }); + await assert.rejects( + () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A' }] }), + { message: 'Shipment amount exceeds ordered amount for article (SH-A)' }, + ); + }); + + it('rejects foreign articles and invalid input', async () => { + const order = await makeOrder(); + await assert.rejects( + () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-UNKNOWN', amount: 1 }] }), + { message: `Article (SH-UNKNOWN) is not part of order (${order.number})` }, + ); + await assert.rejects(() => store.shipment.create(gid, {}), { message: 'Order is required' }); + await assert.rejects(() => store.shipment.create(gid, { orderId: 0 }), { message: 'Order is required' }); + await assert.rejects(() => store.shipment.create(gid, { orderId: order.id, lines: 'x' }), { message: 'Invalid lines' }); + await assert.rejects( + () => store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 0 }] }), + { message: 'Invalid lines' }, + ); + await assert.rejects(() => store.shipment.create(gid, { orderId: 999999 }), { message: 'Order not found' }); + }); + + it('rejects orders without lines', async () => { + const empty = await makeOrder([]); + await assert.rejects(() => store.shipment.create(gid, { orderId: empty.id }), { message: 'Order has no lines' }); + }); + + it('cannot ship orders of another company', async () => { + const order = await makeOrder(); + await assert.rejects( + () => store.shipment.create(other.googleAuthId, { orderId: order.id }), + { message: 'Order not found' }, + ); + }); + + it('honours custom state and external number', async () => { + const order = await makeOrder(); + const s = await store.shipment.create(gid, { + orderId: order.id, + lines: [{ sku: 'SH-A', amount: 1 }], + state: 'delivered', + externalNumber: ' DHL-77 ', + }); + assert.equal(s.state, 'delivered'); + assert.equal(s.externalNumber, 'DHL-77'); + }); + }); + + describe('order.createShipment', () => { + it('is equivalent to shipment.create with the order id', async () => { + const order = await makeOrder(); + const s = await store.order.createShipment(gid, order.id, { lines: [{ sku: 'SH-B', amount: 2 }] }); + assert.equal(s.number, `${order.number}-L-1`); + assert.deepEqual(s.lines.map((l) => [l.sku, l.amount]), [['SH-B', 2]]); + }); + }); + + describe('getById / listByOrderId', () => { + it('returns shipments with lines and packages', async () => { + const order = await makeOrder(); + const s1 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); + const s2 = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-B', amount: 1 }] }); + + const carrier = await store.carrier.create(gid, { name: `DHL ${order.number}`, hasPackages: true }); + await store.shipping.create(gid, { manifestId: s1.id, carrierId: carrier.id, type: 'parcel', state: 'draft', trackingCode: 'T-1' }); + + const fetched = await store.shipment.getById(gid, s1.id); + assert.equal(fetched.id, s1.id); + assert.equal(fetched.lines.length, 1); + assert.equal(fetched.packageCount, 1); + assert.equal(fetched.shippings[0].trackingCode, 'T-1'); + assert.equal(fetched.shippings[0].carrier, carrier.name); + + const byOrder = await store.shipment.listByOrderId(gid, order.id); + assert.deepEqual(byOrder.map((s) => s.id), [s1.id, s2.id]); + assert.equal(byOrder[0].shippings.length, 1); + assert.equal(byOrder[1].shippings.length, 0); + }); + + it('rejects unknown ids, foreign access and invalid ids', async () => { + const order = await makeOrder(); + const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); + + await assert.rejects(() => store.shipment.getById(gid, 999999), { message: 'Shipment not found' }); + await assert.rejects(() => store.shipment.getById(other.googleAuthId, s.id), { message: 'Shipment not found' }); + await assert.rejects(() => store.shipment.getById(gid, 'x'), { message: 'Shipment is required' }); + await assert.rejects(() => store.shipment.listByOrderId(gid, 999999), { message: 'Order not found' }); + await assert.rejects(() => store.shipment.listByOrderId(other.googleAuthId, order.id), { message: 'Order not found' }); + }); + }); + + describe('update', () => { + it('changes the state and validates it', async () => { + const order = await makeOrder(); + const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); + + assert.equal((await store.shipment.update(gid, s.id, { state: 'delivered' })).state, 'delivered'); + assert.equal((await store.shipment.update(gid, s.id, { state: ' RETURNED ' })).state, 'returned'); + assert.equal((await store.shipment.update(gid, s.id, {})).state, 'returned', 'empty patch keeps the state'); + + await assert.rejects(() => store.shipment.update(gid, s.id, { state: 'lost' }), { message: 'Invalid state' }); + await assert.rejects(() => store.shipment.update(gid, 999999, { state: 'new' }), { message: 'Shipment not found' }); + await assert.rejects(() => store.shipment.update(other.googleAuthId, s.id, { state: 'new' }), { message: 'Shipment not found' }); + await assert.rejects(() => store.shipment.update(gid, 0, { state: 'new' }), { message: 'Shipment is required' }); + }); + }); + + describe('delete', () => { + it('deletes an unshipped shipment and reopens the order quantity', async () => { + const order = await makeOrder(); + const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] }); + const summary = () => ctx.provider.db.prepare('SELECT * FROM ordersummary WHERE manifest_id = ?').get(order.id); + assert.equal(summary().shipped_quantity, 10); + + await store.shipment.delete(gid, s.id); + await assert.rejects(() => store.shipment.getById(gid, s.id), { message: 'Shipment not found' }); + assert.equal(summary().shipped_quantity, 0); + + const again = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 10 }] }); + assert.ok(again.id); + }); + + it('refuses to delete a shipment with shipped packages', async () => { + const order = await makeOrder(); + const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 2 }] }); + const carrier = await store.carrier.create(gid, { name: `Carrier ${order.number}`, hasPackages: true }); + const pkg = await store.shipping.create(gid, { manifestId: s.id, carrierId: carrier.id, type: 'parcel', state: 'draft' }); + await store.shipping.update(gid, pkg.id, { state: 'shipped' }); + + await assert.rejects(() => store.shipment.delete(gid, s.id), { message: 'Shipped shipment cannot be deleted' }); + assert.ok(await store.shipment.getById(gid, s.id)); + }); + + it('rejects unknown, foreign and invalid ids', async () => { + const order = await makeOrder(); + const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] }); + await assert.rejects(() => store.shipment.delete(gid, 999999), { message: 'Shipment not found' }); + await assert.rejects(() => store.shipment.delete(other.googleAuthId, s.id), { message: 'Shipment not found' }); + await assert.rejects(() => store.shipment.delete(gid, null), { message: 'Shipment is required' }); + assert.ok(await store.shipment.getById(gid, s.id)); + }); + }); + + describe('list / rebuildSummaries', () => { + let lt; + let o1; + let o2; + let s1; + let s2; + let s3; + + before(async () => { + lt = await createTenant(store, { name: 'Shipment List GmbH' }); + const cust = await createCustomer(store, lt, 'Listy McList'); + await createArticle(store, lt, { sku: 'LS-A' }); + const mk = (number) => store.order.create(lt.googleAuthId, { + number, date: '2026-10-12', entityId: cust.id, + lines: [{ sku: 'LS-A', amount: 10, net: 1, taxrate: 19 }], + }); + o1 = await mk('LIST-1'); + o2 = await mk('LIST-2'); + s1 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 3 }] }); + s2 = await store.shipment.create(lt.googleAuthId, { orderId: o1.id, lines: [{ sku: 'LS-A', amount: 2 }] }); + s3 = await store.shipment.create(lt.googleAuthId, { orderId: o2.id, lines: [{ sku: 'LS-A', amount: 4 }] }); + }); + + it('lists with summary data and pagination', async () => { + const res = await store.shipment.list(lt.googleAuthId, { offset: 0, limit: 10 }); + assert.equal(res.total, 3); + assert.equal(res.unfilteredTotal, 3); + assert.equal(res.limit, 10); + assert.equal(res.offset, 0); + + const row = res.shipments.find((s) => s.id === s1.id); + assert.equal(row.orderNumber, 'LIST-1'); + assert.equal(row.orderId, o1.id); + assert.equal(row.state, 'new'); + assert.equal(row.positions, 1); + assert.equal(row.totalQuantity, 3); + + const page = await store.shipment.list(lt.googleAuthId, { offset: 2, limit: 1 }); + assert.equal(page.shipments.length, 1); + assert.equal(page.total, 3); + }); + + it('returns a plain array without options', async () => { + const all = await store.shipment.list(lt.googleAuthId); + assert.ok(Array.isArray(all)); + assert.equal(all.length, 3); + }); + + it('filters by order, search and state', async () => { + const byOrder = await store.shipment.list(lt.googleAuthId, { orderId: o1.id }); + assert.deepEqual(byOrder.shipments.map((s) => s.id).sort(), [s1.id, s2.id].sort()); + + const bySearch = await store.shipment.list(lt.googleAuthId, { search: 'LIST-2' }); + assert.deepEqual(bySearch.shipments.map((s) => s.id), [s3.id]); + assert.equal(bySearch.unfilteredTotal, 3); + + await store.shipment.update(lt.googleAuthId, s1.id, { state: 'delivered' }); + const delivered = await store.shipment.list(lt.googleAuthId, { state: 'delivered' }); + assert.deepEqual(delivered.shipments.map((s) => s.id), [s1.id]); + }); + + it('validates options', async () => { + const l = (o) => store.shipment.list(lt.googleAuthId, o); + await assert.rejects(() => l({ offset: -1 }), { message: 'Invalid offset' }); + await assert.rejects(() => l({ limit: 0 }), { message: 'Invalid limit' }); + await assert.rejects(() => l({ seekMonths: 1.5 }), { message: 'Invalid seekMonths' }); + }); + + it('rebuildSummaries preserves state and quantities', async () => { + await store.shipment.update(lt.googleAuthId, s2.id, { state: 'returned' }); + await store.shipment.rebuildSummaries(lt.googleAuthId); + + const returned = await store.shipment.list(lt.googleAuthId, { state: 'returned' }); + assert.deepEqual(returned.shipments.map((s) => s.id), [s2.id]); + assert.equal(returned.shipments[0].totalQuantity, 2); + assert.equal(returned.shipments[0].orderNumber, 'LIST-1'); + }); + + it('does not leak shipments of other companies', async () => { + const mine = await store.shipment.list(gid, { limit: 1000, offset: 0 }); + assert.ok(!mine.shipments.some((s) => [s1.id, s2.id, s3.id].includes(s.id))); + }); + }); +});