765 lines
33 KiB
JavaScript
765 lines
33 KiB
JavaScript
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createTestStore, createTenant, createArticle, createCustomer, 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');
|
|
});
|
|
});
|
|
|
|
describe('undelivered customer orders', () => {
|
|
it('returns total remaining unshipped quantity across open orders', async () => {
|
|
const art = await createArticle(store, tenant, { name: 'Order Article', sku: 'ORD-TEST-1' });
|
|
const cust = await createCustomer(store, tenant, 'Order Customer');
|
|
|
|
let res = await store.article.getBySku(gid, art.sku);
|
|
assert.deepEqual(res.article.undeliveredOrders, []);
|
|
|
|
const o1 = await store.order.create(gid, {
|
|
number: 'ORD-1001',
|
|
date: '2026-10-20',
|
|
entityId: cust.id,
|
|
lines: [{ sku: art.sku, name: 'Item', amount: 10, net: 5, taxrate: 19 }],
|
|
});
|
|
|
|
res = await store.article.getBySku(gid, art.sku);
|
|
assert.equal(res.article.undeliveredOrders.length, 1);
|
|
assert.equal(res.article.undeliveredOrders[0].orderId, o1.id);
|
|
assert.equal(res.article.undeliveredOrders[0].orderNumber, 'ORD-1001');
|
|
assert.equal(res.article.undeliveredOrders[0].customerName, 'Order Customer');
|
|
assert.equal(res.article.undeliveredOrders[0].amount, 10);
|
|
|
|
const o2 = await store.order.create(gid, {
|
|
number: 'ORD-1002',
|
|
date: '2026-10-21',
|
|
entityId: cust.id,
|
|
lines: [{ sku: art.sku, name: 'Item', amount: 5, net: 5, taxrate: 19 }],
|
|
});
|
|
|
|
res = await store.article.getBySku(gid, art.sku);
|
|
assert.equal(res.article.undeliveredOrders.length, 2);
|
|
assert.equal(res.article.undeliveredOrders[0].orderNumber, 'ORD-1001');
|
|
assert.equal(res.article.undeliveredOrders[0].amount, 10);
|
|
assert.equal(res.article.undeliveredOrders[1].orderNumber, 'ORD-1002');
|
|
assert.equal(res.article.undeliveredOrders[1].amount, 5);
|
|
|
|
await store.shipment.create(gid, {
|
|
orderId: o1.id,
|
|
lines: [{ sku: art.sku, amount: 4 }],
|
|
});
|
|
|
|
res = await store.article.getBySku(gid, art.sku);
|
|
assert.equal(res.article.undeliveredOrders.length, 2);
|
|
assert.equal(res.article.undeliveredOrders[0].amount, 6);
|
|
assert.equal(res.article.undeliveredOrders[1].amount, 5);
|
|
|
|
await store.shipment.create(gid, {
|
|
orderId: o1.id,
|
|
lines: [{ sku: art.sku, amount: 6 }],
|
|
});
|
|
|
|
res = await store.article.getBySku(gid, art.sku);
|
|
assert.equal(res.article.undeliveredOrders.length, 1);
|
|
assert.equal(res.article.undeliveredOrders[0].orderNumber, 'ORD-1002');
|
|
assert.equal(res.article.undeliveredOrders[0].amount, 5);
|
|
|
|
await store.shipment.create(gid, {
|
|
orderId: o2.id,
|
|
lines: [{ sku: art.sku, amount: 5 }],
|
|
});
|
|
|
|
res = await store.article.getBySku(gid, art.sku);
|
|
assert.deepEqual(res.article.undeliveredOrders, []);
|
|
});
|
|
});
|
|
});
|
|
|