Files
dashlibstorage/test/article.test.js
2026-10-19 02:28:15 +02:00

698 lines
31 KiB
JavaScript

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' },
);
const history = await store.article.getHistoryBySku(gid, art.sku);
const texts = history.map((h) => h.text);
assert.ok(texts.some((t) => t.includes('Bild hinzugefügt')));
assert.ok(texts.some((t) => t.includes('Bildreihenfolge geändert')));
assert.ok(texts.some((t) => t.includes('Bild gelöscht')));
});
});
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, []);
});
});
describe('supplier incoming deliveries', () => {
it('returns incoming dates and unbooked amounts for each supplier', async () => {
const art = await createArticle(store, tenant, { name: 'Incoming Article', sku: 'INC-ART-1' });
const supp = await store.supplier.create(gid, { name: 'Supplier Incoming' });
await store.articlesupplier.create(gid, art.sku, {
entityId: supp.id,
sku: 'SUP-SKU-1',
pu: 10,
});
let res = await store.article.getBySku(gid, art.sku);
assert.equal(res.article.suppliers.length, 1);
assert.deepEqual(res.article.suppliers[0].incoming, []);
const d1 = await store.delivery.create(gid, {
supplierId: supp.id,
date: '2026-11-01T00:00:00.000Z',
lines: [{ sku: 'SUP-SKU-1', name: 'Item', pu: 10, amount: 25, net: 5, taxrate: 19 }],
});
const d2 = await store.delivery.create(gid, {
supplierId: supp.id,
date: '2026-11-15T00:00:00.000Z',
lines: [{ sku: 'SUP-SKU-1', name: 'Item', pu: 10, amount: 15, net: 5, taxrate: 19 }],
});
res = await store.article.getBySku(gid, art.sku);
assert.equal(res.article.suppliers[0].incoming.length, 2);
assert.equal(res.article.suppliers[0].incoming[0].amount, 25);
assert.equal(res.article.suppliers[0].incoming[0].date, '2026-11-01T00:00:00.000Z');
assert.equal(res.article.suppliers[0].incoming[1].amount, 15);
assert.equal(res.article.suppliers[0].incoming[1].date, '2026-11-15T00:00:00.000Z');
const wh = await store.warehouse.create(gid, { name: 'Inc WH' });
await store.stock.accept(gid, { deliveryId: d1.id, warehouseId: wh.id });
res = await store.article.getBySku(gid, art.sku);
assert.equal(res.article.suppliers[0].incoming.length, 1);
assert.equal(res.article.suppliers[0].incoming[0].amount, 15);
assert.equal(res.article.suppliers[0].incoming[0].date, '2026-11-15T00:00:00.000Z');
});
});
});