u
This commit is contained in:
648
test/article.test.js
Normal file
648
test/article.test.js
Normal file
@@ -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: '<p>Lecker</p>', 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, '<p>Lecker</p>');
|
||||
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, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
335
test/category.test.js
Normal file
335
test/category.test.js
Normal file
@@ -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' },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
303
test/delivery.test.js
Normal file
303
test/delivery.test.js
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
89
test/helpers.js
Normal file
89
test/helpers.js
Normal file
@@ -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();
|
||||
446
test/ledger.test.js
Normal file
446
test/ledger.test.js
Normal file
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
330
test/shipment.test.js
Normal file
330
test/shipment.test.js
Normal file
@@ -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)));
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user