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('includes child articles (variants) in the category', async () => { const cat = await create('Variants Cat'); const attr = await store.attributename.create(tenant.googleAuthId, { name: 'Größe' }); const parent = await createArticle(store, tenant, { parentId: cat.id, name: 'Shirt Parent' }); await store.article.toggleParent(tenant.googleAuthId, parent.sku, { isParent: true, attributeNameId: attr.id }); const child = await store.article.addChild(tenant.googleAuthId, parent.sku, { value: 'XL' }); const list = await store.category.getCategoryArticles(tenant.googleAuthId, cat.id); const skus = list.map((a) => a.sku); assert.ok(skus.includes(parent.sku)); assert.ok(skus.includes(child.article.sku)); const childItem = list.find((a) => a.sku === child.article.sku); assert.equal(childItem.parentArticleId, parent.id); assert.equal(childItem.parentSku, parent.sku); }); 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/, ); }); it('touches updated_at on moved category, subcategories, and associated articles', async () => { const topTarget = await create('Move Target'); const movedCat = await create('Moved Cat'); const subCat = await create('Sub Cat', movedCat.id); const artMoved = await createArticle(store, tenant, { parentId: movedCat.id, name: 'Article in Moved' }); const artSub = await createArticle(store, tenant, { parentId: subCat.id, name: 'Article in Sub' }); const oldDate = '2020-01-01T00:00:00.000Z'; await ctx.provider.query( `UPDATE category SET updated_at = '${oldDate}' WHERE id IN (${movedCat.id}, ${subCat.id})` ); await ctx.provider.query( `UPDATE article SET updated_at = '${oldDate}' WHERE id IN (${artMoved.id}, ${artSub.id})` ); await store.category.move({ googleAuthId: tenant.googleAuthId, id: movedCat.id, parentId: topTarget.id, }); const cats = await ctx.provider.query( `SELECT id, updated_at FROM category WHERE id IN (${movedCat.id}, ${subCat.id})` ); for (const row of cats.rows) { assert.ok(new Date(row.updated_at) > new Date(oldDate), `Category ${row.id} updated_at was touched`); } const arts = await ctx.provider.query( `SELECT id, updated_at FROM article WHERE id IN (${artMoved.id}, ${artSub.id})` ); for (const row of arts.rows) { assert.ok(new Date(row.updated_at) > new Date(oldDate), `Article ${row.id} updated_at was touched`); } }); }); 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' }, ); }); }); describe('generateImage', () => { it('rejects invalid size', async () => { const cat = await create('Gen Invalid Size'); await assert.rejects( () => store.category.generateImage(tenant.googleAuthId, cat.id, { size: 5 }), { message: 'Invalid size, must be 2, 3, or 4' }, ); }); it('rejects category without any article images', async () => { const cat = await create('Gen Empty Cat'); await assert.rejects( () => store.category.generateImage(tenant.googleAuthId, cat.id), { message: 'No images found for category articles' }, ); }); it('falls back to 2x2 when fewer than 7 images are available', async () => { const cat = await create('Mosaic Parent Cat'); const sub = await create('Mosaic Sub Cat', cat.id); // Create 2 articles in parent, 2 articles in subcategory with images for (let i = 0; i < 2; i++) { const art = await createArticle(store, tenant, { parentId: cat.id, name: `Parent Art ${i}` }); await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 50 * i, g: 100, b: 150 })); } for (let i = 0; i < 2; i++) { const art = await createArticle(store, tenant, { parentId: sub.id, name: `Sub Art ${i}` }); await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 150, g: 50 * i, b: 200 })); } // 4 images available (< 7), default without size should fall back to 2x2 const res = await store.category.generateImage(tenant.googleAuthId, cat.id); assert.equal(res.id, cat.id); assert.equal(res.size, 2); assert.equal(res.grid, '2x2'); assert.equal(res.articleCount, 4); assert.ok(res.fullHash); assert.ok(res.thumbW > 0); // Category tree exposes the new mosaic image const treeCat = await findInTree(cat.id); assert.equal(treeCat.fullHash, res.fullHash); }); it('uses 3x3 when 7 or more images are available or explicit size=3', async () => { const cat = await create('Mosaic 3x3 Cat'); for (let i = 0; i < 7; i++) { const art = await createArticle(store, tenant, { parentId: cat.id, name: `3x3 Art ${i}` }); await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 30 * i, g: 70, b: 90 })); } const res = await store.category.generateImage(tenant.googleAuthId, cat.id); assert.equal(res.id, cat.id); assert.equal(res.size, 3); assert.equal(res.grid, '3x3'); assert.equal(res.articleCount, 7); assert.ok(res.fullHash); }); it('supports explicit size parameter (e.g. size=4)', async () => { const cat = await create('Mosaic 4x4 Cat'); for (let i = 0; i < 4; i++) { const art = await createArticle(store, tenant, { parentId: cat.id, name: `4x4 Art ${i}` }); await store.article.addImage(tenant.googleAuthId, art.sku, await makePng({ r: 40 * i, g: 120, b: 50 })); } const res = await store.category.generateImage(tenant.googleAuthId, cat.id, { size: 4 }); assert.equal(res.id, cat.id); assert.equal(res.size, 4); assert.equal(res.grid, '4x4'); assert.ok(res.fullHash); }); }); });