349 lines
13 KiB
JavaScript
349 lines
13 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.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);
|
|
assert.equal(list[0].smallThumbW, null);
|
|
assert.equal(list[0].smallThumbH, null);
|
|
assert.equal(list[0].smallThumbBinary, null);
|
|
assert(Array.isArray(list[0].articlewarehousesummary));
|
|
assert(Array.isArray(list[0].warehouses));
|
|
|
|
await store.article.addImage(tenant.googleAuthId, art.sku, await makePng());
|
|
const listWithImg = await store.category.getCategoryArticles(tenant.googleAuthId, cat.id);
|
|
assert.equal(listWithImg.length, 1);
|
|
assert.ok(listWithImg[0].smallThumbW > 0);
|
|
assert.ok(listWithImg[0].smallThumbH > 0);
|
|
assert.equal(typeof listWithImg[0].smallThumbBinary, 'string');
|
|
assert.ok(listWithImg[0].smallThumbBinary.length > 0);
|
|
});
|
|
|
|
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' },
|
|
);
|
|
});
|
|
});
|
|
});
|