Files
dashlibstorage/test/category.test.js
2026-10-20 23:16:47 +02:00

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' },
);
});
});
});