This commit is contained in:
seb
2026-10-08 16:11:44 +02:00
parent 46ea04c0cb
commit d36c2827cc
4 changed files with 406 additions and 38 deletions

View File

@@ -59,7 +59,7 @@ const category = await store.category.createUnderParent({ googleAuthId: 42, pare
*Source: `src/store/category/delete.js`*
Deletes a category, refusing to delete the company's root category.
Deletes a category, refusing to delete the company's root category. If recursive is true, recursively deletes the subtree and articles that only belong to it.
**Parameters**
@@ -68,6 +68,7 @@ Deletes a category, refusing to delete the company's root category.
| `input` | `object` | The delete input. |
| `input.googleAuthId` | `number` | The id of the Google auth user. |
| `input.id` | `number` | The id of the category to delete. |
| `[input.recursive=false]` | `boolean` | Whether to delete the category subtree recursively. |
| `[tx]` | `object` | Optional transaction to run within. |
**Returns**
@@ -76,12 +77,13 @@ Deletes a category, refusing to delete the company's root category.
**Throws**
`Error` — If the category is the root category or contains articles/subcategories.
`Error` — If the category is the root category or contains articles/subcategories (when recursive is false).
**Example**
```js
await store.category.delete({ googleAuthId: 42, id: 5 });
await store.category.delete({ googleAuthId: 42, id: 5, recursive: true });
```
## `category.deleteImage`

2
API.md
View File

@@ -76,7 +76,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met
- [category](API.category.md)
- `addImage(googleAuthId: number, categoryId: number, image: Buffer, tx?: object): Promise<{id: number, fullHash: string, thumbW: number, thumbH: number, thumbHash: string}>`
- `createUnderParent(input: {googleAuthId: number, parentId: number, name: string}, tx?: object): Promise<{id: number, parentId: number, sort: number, name: string, slug: string}>`
- `delete(input: {googleAuthId: number, id: number}, tx?: object): Promise<void>`
- `delete(input: {googleAuthId: number, id: number, recursive?: boolean}, tx?: object): Promise<void>`
- `deleteImage(googleAuthId: number, categoryId: number, tx?: object): Promise<{id: number, thumbW: null, thumbH: null, thumbHash: null}>`
- `ensureByPath(input: {googleAuthId: number, parentId: number, path: string}, tx?: object): Promise<number>`
- `getCategoryArticles(googleAuthId: number, categoryId: number, tx?: object): Promise<Array<{id: number, sku: string, name: string, slug: string, net: number|null, taxRate: number|null, optionsCount: number, articleListCount: number, childCount: number, parentArticleId: number|null, parentSku: string|null, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null, brand: {id: number|null, name: string, fullHash: string|null, thumbW: number|null, thumbH: number|null, thumbHash: string|null}|null}>>`

View File

@@ -0,0 +1,214 @@
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import LibDashStore from '../src/index.js';
import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js';
async function run() {
console.log('--- Testing category recursive deletion ---');
const tempDir = mkdtempSync(join(tmpdir(), 'test-cat-del-rec-'));
const dbFile = join(tempDir, 'test.db');
const storageDir = join(tempDir, 'storage');
const provider = new BetterSqlite3Provider({
filename: dbFile,
storageDir,
});
await provider.initialize();
const store = await new LibDashStore(provider);
try {
const googleAuthId = await store.google_auth.add('tester@example.com');
const company = await store.company.create({
name: 'Recursive Test Corp',
iso639: 'de',
taxrate: 19,
reducedtaxrate: 7,
googleAuthId,
});
await store.google_auth.setSelectedCompany(googleAuthId, company.id);
const rootId = company.rootCategoryId;
// 1. Refuse to delete root category, even with recursive: true
console.log('1. Testing refusal to delete root category...');
await assert.rejects(
() => store.category.delete({ googleAuthId, id: rootId, recursive: true }),
/Cannot delete root category/
);
// 2. Build category tree:
// Root
// ├── OutsideCat (for preserved articles)
// └── MainCat (to be recursively deleted)
// ├── SubCat1
// │ └── SubSubCat1
// └── SubCat2
console.log('2. Building category tree...');
const outsideCat = await store.category.createUnderParent({
googleAuthId,
parentId: rootId,
name: 'Outside Category',
});
const mainCat = await store.category.createUnderParent({
googleAuthId,
parentId: rootId,
name: 'Main Category',
});
const subCat1 = await store.category.createUnderParent({
googleAuthId,
parentId: mainCat.id,
name: 'Sub Category 1',
});
const subSubCat1 = await store.category.createUnderParent({
googleAuthId,
parentId: subCat1.id,
name: 'Sub Sub Category 1',
});
const subCat2 = await store.category.createUnderParent({
googleAuthId,
parentId: mainCat.id,
name: 'Sub Category 2',
});
// 3. Test non-recursive delete guards
console.log('3. Testing non-recursive delete guards...');
await assert.rejects(
() => store.category.delete({ googleAuthId, id: mainCat.id, recursive: false }),
/Category has subcategories/
);
// 4. Create articles:
// Article 1: Only in SubSubCat1 -> Should be deleted
// Article 2: In SubCat1 AND OutsideCat -> Should survive, removed from SubCat1
// Article 3: Parent in SubCat2 with a variant child -> Both should be deleted
// Article 4: Outside article in OutsideCat -> Untouched
console.log('4. Creating articles in categories...');
const artOnlySub = await store.article.createUnderParent({
googleAuthId,
parentId: subSubCat1.id,
name: 'Only SubSubCat Article',
});
const artMulti = await store.article.createUnderParent({
googleAuthId,
parentId: subCat1.id,
name: 'Multi Category Article',
});
// Add outsideCat to artMulti
await store.article.setCategoryIdsBySku(googleAuthId, artMulti.sku, [subCat1.id, outsideCat.id]);
const attrName = await store.attributename.create(googleAuthId, { name: 'Size' });
const artParent = await store.article.createUnderParent({
googleAuthId,
parentId: subCat2.id,
name: 'Parent Article In Sub2',
});
await store.article.toggleParent(googleAuthId, artParent.sku, {
isParent: true,
attributeNameId: attrName.id,
});
const artChild = await store.article.addChild(googleAuthId, artParent.sku, {
value: 'M',
});
const artOutside = await store.article.createUnderParent({
googleAuthId,
parentId: outsideCat.id,
name: 'Purely Outside Article',
});
// 5. Test POS terminal assignment unlinking on recursive delete
console.log('5. Setting up POS terminal assignment...');
const posTerminal = await store.posterminal.upsert(googleAuthId, {
name: 'POS Register 1',
pairingpin: '1234',
});
await store.posterminal.setOn(googleAuthId, posTerminal.id, subCat2.id);
await store.posterminal.setOn(googleAuthId, posTerminal.id, outsideCat.id);
const posCatsBefore = provider.db.prepare(
'SELECT category_id FROM posterminalcategory WHERE posterminal_id = ?'
).all(posTerminal.id).map(r => r.category_id);
assert.ok(posCatsBefore.includes(subCat2.id));
assert.ok(posCatsBefore.includes(outsideCat.id));
// 6. Execute recursive deletion of mainCat
console.log('6. Executing recursive category delete on mainCat...');
await store.category.delete({
googleAuthId,
id: mainCat.id,
recursive: true,
});
// 7. Verify category tree state
console.log('7. Verifying category tree...');
const catsRemaining = provider.db.prepare('SELECT id FROM category').all().map(r => r.id);
assert.ok(catsRemaining.includes(rootId));
assert.ok(catsRemaining.includes(outsideCat.id));
assert.ok(!catsRemaining.includes(mainCat.id));
assert.ok(!catsRemaining.includes(subCat1.id));
assert.ok(!catsRemaining.includes(subSubCat1.id));
assert.ok(!catsRemaining.includes(subCat2.id));
// 8. Verify POS terminal assignment was unlinked for deleted category
console.log('8. Verifying POS terminal categories...');
const posCatsAfter = provider.db.prepare(
'SELECT category_id FROM posterminalcategory WHERE posterminal_id = ?'
).all(posTerminal.id).map(r => r.category_id);
assert.ok(!posCatsAfter.includes(subCat2.id));
assert.ok(posCatsAfter.includes(outsideCat.id));
// 9. Verify article states:
console.log('9. Verifying article states...');
// artOnlySub: deleted
await assert.rejects(
() => store.article.getBySku(googleAuthId, artOnlySub.sku),
/Article not found/
);
// tombstone for artOnlySub exists
const t1 = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artOnlySub.sku);
assert.equal(t1.length, 1);
// artParent and its child artChild: both deleted
await assert.rejects(
() => store.article.getBySku(googleAuthId, artParent.sku),
/Article not found/
);
await assert.rejects(
() => store.article.getBySku(googleAuthId, artChild.article.sku),
/Article not found/
);
const tParent = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artParent.sku);
const tChild = provider.db.prepare('SELECT * FROM articletombstone WHERE sku = ?').all(artChild.article.sku);
assert.equal(tParent.length, 1);
assert.equal(tChild.length, 1);
// artMulti: continues to exist!
const artMultiDetail = await store.article.getBySku(googleAuthId, artMulti.sku);
assert.equal(artMultiDetail.article.sku, artMulti.sku);
// artMulti categoryarticle now ONLY contains outsideCat
const artMultiCats = provider.db.prepare(
'SELECT category_id FROM categoryarticle WHERE article_id = ?'
).all(artMultiDetail.article.id).map(r => r.category_id);
assert.deepEqual(artMultiCats, [outsideCat.id]);
// artOutside: completely untouched
const artOutsideDetail = await store.article.getBySku(googleAuthId, artOutside.sku);
assert.equal(artOutsideDetail.article.sku, artOutside.sku);
console.log('✅ ALL RECURSIVE CATEGORY DELETE TESTS PASSED');
} finally {
try {
rmSync(tempDir, { recursive: true, force: true });
} catch {}
}
}
run().catch((err) => {
console.error('Test failed:', err);
process.exit(1);
});

View File

@@ -1,26 +1,36 @@
import { DELETE as MSSQL_DELETE } from '../../providers/mssql/queries/category.js';
import { DELETE as SQLITE_DELETE } from '../../providers/sqlite3/queries/category.js';
import * as mssqlArticleDelete from '../../providers/mssql/queries/articleDelete.js';
import * as sqliteArticleDelete from '../../providers/sqlite3/queries/articleDelete.js';
import * as mssqlArticle from '../../providers/mssql/queries/article.js';
import * as sqliteArticle from '../../providers/sqlite3/queries/article.js';
import resolveCategoryAccess from './resolveCategoryAccess.js';
import makeRunner from '../runner.js';
export default function del(provider) {
const access = resolveCategoryAccess(provider);
const sql = provider?.dialect === 'sqlite3' ? SQLITE_DELETE : MSSQL_DELETE;
const isSqlite = provider?.dialect === 'sqlite3';
const sql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
const articleDeleteQueries = isSqlite ? sqliteArticleDelete : mssqlArticleDelete;
const articleQueries = isSqlite ? sqliteArticle : mssqlArticle;
/**
* Deletes a category, refusing to delete the company's root category.
* If recursive is true, recursively deletes the subtree and articles that only belong to it.
*
* @param {object} input - The delete input.
* @param {number} input.googleAuthId - The id of the Google auth user.
* @param {number} input.id - The id of the category to delete.
* @param {boolean} [input.recursive=false] - Whether to delete the category subtree recursively.
* @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<void>}
* @throws {Error} If the category is the root category or contains articles/subcategories.
* @throws {Error} If the category is the root category or contains articles/subcategories (when recursive is false).
*
* @example
* await store.category.delete({ googleAuthId: 42, id: 5 });
* await store.category.delete({ googleAuthId: 42, id: 5, recursive: true });
*/
return async ({ googleAuthId, id }, tx) => {
return async ({ googleAuthId, id, recursive = false }, tx) => {
const run = async (runner) => {
const { rootCategoryId } = await access(googleAuthId, id, runner);
@@ -28,41 +38,183 @@ export default function del(provider) {
throw new Error('Cannot delete root category');
}
const childRes = await runner.query(
'SELECT id FROM category WHERE parent_id = @id',
{ id },
);
if (childRes.rows?.length > 0) {
throw new Error('Category has subcategories');
}
if (!recursive) {
const childRes = await runner.query(
'SELECT id FROM category WHERE parent_id = @id',
{ id },
);
if (childRes.rows?.length > 0) {
throw new Error('Category has subcategories');
}
const artRes = await runner.query(
'SELECT article_id FROM categoryarticle WHERE category_id = @id',
{ id },
);
if (artRes.rows?.length > 0) {
throw new Error('Category has articles');
}
const posRes = await runner.query(
'SELECT posterminal_id FROM posterminalcategory WHERE category_id = @id',
{ id },
);
if (posRes.rows?.length > 0) {
throw new Error('Category is assigned to POS terminals');
}
try {
await runner.query(sql, { id });
} catch (err) {
if (
err.message?.includes('FOREIGN KEY constraint failed') ||
err.message?.includes('foreign key constraint') ||
err.message?.includes('REFERENCE constraint')
) {
const artRes = await runner.query(
'SELECT article_id FROM categoryarticle WHERE category_id = @id',
{ id },
);
if (artRes.rows?.length > 0) {
throw new Error('Category has articles');
}
throw err;
const posRes = await runner.query(
'SELECT posterminal_id FROM posterminalcategory WHERE category_id = @id',
{ id },
);
if (posRes.rows?.length > 0) {
throw new Error('Category is assigned to POS terminals');
}
try {
await runner.query(sql, { id });
} catch (err) {
if (
err.message?.includes('FOREIGN KEY constraint failed') ||
err.message?.includes('foreign key constraint') ||
err.message?.includes('REFERENCE constraint')
) {
throw new Error('Category has articles');
}
throw err;
}
return;
}
// 1. Collect all category IDs in the subtree (BFS)
const subtreeIds = [];
const queue = [id];
while (queue.length > 0) {
const currentId = queue.shift();
subtreeIds.push(currentId);
const childRes = await runner.query(
'SELECT id FROM category WHERE parent_id = @id',
{ id: currentId },
);
for (const row of childRes.rows) {
queue.push(row.id);
}
}
const subtreeSet = new Set(subtreeIds);
// 2. Unassign POS terminal categories for the subtree
for (const catId of subtreeIds) {
await runner.query(
'DELETE FROM posterminalcategory WHERE category_id = @categoryId',
{ categoryId: catId },
);
}
// 3. Find all articles assigned to any category in the subtree
const affectedArticleIds = new Set();
for (const catId of subtreeIds) {
const artRes = await runner.query(
'SELECT article_id FROM categoryarticle WHERE category_id = @categoryId',
{ categoryId: catId },
);
for (const row of artRes.rows) {
affectedArticleIds.add(row.article_id);
}
}
// 4. For each article, determine if it has categories outside the subtree
const articlesToDelete = [];
for (const articleId of affectedArticleIds) {
const allCatsRes = await runner.query(
'SELECT category_id FROM categoryarticle WHERE article_id = @articleId',
{ articleId },
);
const remainingCats = allCatsRes.rows
.map((r) => r.category_id)
.filter((catId) => !subtreeSet.has(catId));
if (remainingCats.length > 0) {
// Article remains in another category: remove assignment to deleted subtree categories
for (const catRow of allCatsRes.rows) {
if (subtreeSet.has(catRow.category_id)) {
await runner.query(
'DELETE FROM categoryarticle WHERE category_id = @categoryId AND article_id = @articleId',
{ categoryId: catRow.category_id, articleId },
);
}
}
} else {
// Article only belongs to categories in the deleted subtree: will be deleted
articlesToDelete.push(articleId);
}
}
// Helper to delete an article and its child articles recursively
const deleteArticleAndLists = async (artId) => {
const listIds = await runner.query(articleDeleteQueries.GET_LIST_IDS, { articleId: artId });
const { articlelistId, optionslistId } = listIds.rows[0] ?? {};
await runner.query(articleDeleteQueries.CREATE_TOMBSTONE, { articleId: artId });
await runner.query(articleDeleteQueries.DELETE_PICS_BY_ARTICLE, { articleId: artId });
const deleted = await runner.query(articleDeleteQueries.DELETE_BY_ID, { articleId: artId });
if (!deleted.rowsAffected) {
throw new Error('Article not found');
}
if (articlelistId != null) {
await runner.query(articleDeleteQueries.DELETE_ARTICLELIST, { id: articlelistId });
}
if (optionslistId != null) {
await runner.query(articleDeleteQueries.DELETE_OPTIONSLIST, { id: optionslistId });
}
};
const deleteArticleSubtree = async (artId) => {
const children = await runner.query(articleDeleteQueries.GET_CHILDREN_IDS, { id: artId });
for (const child of children.rows) {
await deleteArticleSubtree(child.id);
}
await deleteArticleAndLists(artId);
};
// 5. Delete articles and their children
for (const artId of articlesToDelete) {
// Check if article still exists (might have already been deleted as a child of another article)
const artRes = await runner.query(
'SELECT id, parentarticle_id FROM article WHERE id = @articleId',
{ articleId: artId },
);
if (!artRes.rows || artRes.rows.length === 0) {
continue;
}
const parentArticleId = artRes.rows[0].parentarticle_id;
await deleteArticleSubtree(artId);
// If it was a child of a surviving parent, re-sort the surviving parent's remaining children
if (parentArticleId != null) {
const parentExists = await runner.query(
'SELECT id FROM article WHERE id = @parentArticleId',
{ parentArticleId },
);
if (parentExists.rows?.length > 0) {
const remainingChildrenRes = await runner.query(
'SELECT id, parentsort FROM article WHERE parentarticle_id = @parentArticleId ORDER BY parentsort ASC, id ASC',
{ parentArticleId },
);
for (let i = 0; i < remainingChildrenRes.rows.length; i++) {
const childRow = remainingChildrenRes.rows[i];
if (Number(childRow.parentsort) !== i) {
await runner.query(articleQueries.SET_PARENT_SORT, {
articleId: childRow.id,
parentArticleId,
parentsort: i,
});
}
}
}
}
}
// 6. Delete categories in bottom-up order (reverse BFS)
const reversedIds = [...subtreeIds].reverse();
for (const catId of reversedIds) {
await runner.query(sql, { id: catId });
}
};