fix(admin): delete feedback alerts + informative block errors (issue #127)

- locations/categories GET pass req.query.error/success to views
- locations/categories EJS render dismissible error/success alerts (modeled on catalog.ejs)
- Delete errors include blocking counts + 'Remove X first' hints
- 🔒 lock hint on rows with linked categories/products/subcategories
- 18 new tests (adminDeleteFeedback.test.js); 48 total pass
This commit is contained in:
NW
2026-08-04 17:05:06 +01:00
parent bd3391c111
commit 88a15acb29
6 changed files with 238 additions and 10 deletions

View File

@@ -19,6 +19,7 @@
- **fix**: BUG-03 — per-user callback lock (1500ms debounce) prevents duplicate messages on double-tap
- **fix**: BUG-04 — resetUserContext clears stale inline keyboards/state on main-menu navigation and /start
- **fix**: handlePay uses validated numeric quantity for price/stock/purchase writes (was raw string)
- **fix**: Admin delete feedback — locations/categories pages now render error/success alerts; delete errors include blocking counts + hints; 🔒 lock hint on rows with links
### v1.2.1 — 2026-07-18
- **refactor**: Removed deposit amount-selection step (redundant); deposit_wallet_ now goes directly to Mercuryo instructions

View File

@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import ejs from 'ejs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const viewsDir = join(__dirname, '..', 'admin', 'views');
const locationsTemplate = readFileSync(join(viewsDir, 'locations.ejs'), 'utf-8');
const categoriesTemplate = readFileSync(join(viewsDir, 'categories.ejs'), 'utf-8');
const routesDir = join(__dirname, '..', 'admin', 'routes');
const locationsSource = readFileSync(join(routesDir, 'locations.js'), 'utf-8');
const categoriesSource = readFileSync(join(routesDir, 'categories.js'), 'utf-8');
describe('admin delete feedback UI', () => {
describe('locations.ejs', () => {
it('renders a danger alert when error is set', () => {
const html = ejs.render(locationsTemplate, {
error: 'Cannot delete location with categories (count: 3). Remove categories first.',
success: '',
csrfToken: 'csrf',
locations: []
});
expect(html).toContain('alert-danger');
expect(html).toContain('Cannot delete location with categories (count: 3). Remove categories first.');
});
it('does not render an error alert when error is empty', () => {
const html = ejs.render(locationsTemplate, {
error: '',
success: '',
csrfToken: 'csrf',
locations: []
});
expect(html).not.toContain('alert-danger');
});
it('renders a success alert when success is set', () => {
const html = ejs.render(locationsTemplate, {
error: '',
success: 'Location deleted',
csrfToken: 'csrf',
locations: []
});
expect(html).toContain('alert-success');
expect(html).toContain('Location deleted');
});
it('renders the lock hint when a location has categories', () => {
const html = ejs.render(locationsTemplate, {
error: '',
success: '',
csrfToken: 'csrf',
locations: [{ id: 1, country: 'DE', city: 'Berlin', district: '', category_count: 2, product_count: 0, is_active: 1 }]
});
expect(html).toContain('🔒');
});
it('renders the lock hint when a location has products', () => {
const html = ejs.render(locationsTemplate, {
error: '',
success: '',
csrfToken: 'csrf',
locations: [{ id: 1, country: 'DE', city: 'Berlin', district: '', category_count: 0, product_count: 5, is_active: 1 }]
});
expect(html).toContain('🔒');
});
it('does not render the lock hint when a location has no links', () => {
const html = ejs.render(locationsTemplate, {
error: '',
success: '',
csrfToken: 'csrf',
locations: [{ id: 1, country: 'DE', city: 'Berlin', district: '', category_count: 0, product_count: 0, is_active: 1 }]
});
expect(html).not.toContain('🔒');
});
});
describe('categories.ejs', () => {
const baseData = {
error: '',
success: '',
csrfToken: 'csrf',
locations: [],
subcategories: [],
subcategoriesByCategory: {}
};
it('renders a danger alert when error is set', () => {
const html = ejs.render(categoriesTemplate, {
...baseData,
error: 'Cannot delete category with products (count: 2). Remove products first.',
categories: []
});
expect(html).toContain('alert-danger');
expect(html).toContain('Cannot delete category with products (count: 2). Remove products first.');
});
it('renders a success alert when success is set', () => {
const html = ejs.render(categoriesTemplate, {
...baseData,
success: 'Category deleted',
categories: []
});
expect(html).toContain('alert-success');
expect(html).toContain('Category deleted');
});
it('renders the lock hint when a category has products', () => {
const html = ejs.render(categoriesTemplate, {
...baseData,
categories: [{ id: 1, name: 'Food', product_count: 3, is_active: 1, country: null, city: null }]
});
expect(html).toContain('🔒');
});
it('renders the lock hint when a category has subcategories', () => {
const html = ejs.render(categoriesTemplate, {
...baseData,
categories: [{ id: 1, name: 'Food', product_count: 0, is_active: 1, country: null, city: null }],
subcategories: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 0, is_active: 1 }],
subcategoriesByCategory: { 1: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 0, is_active: 1 }] }
});
expect(html).toContain('🔒');
});
it('renders the lock hint for a subcategory with products', () => {
const html = ejs.render(categoriesTemplate, {
...baseData,
categories: [{ id: 1, name: 'Food', product_count: 0, is_active: 1, country: null, city: null }],
subcategories: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 4, is_active: 1 }],
subcategoriesByCategory: { 1: [{ id: 10, category_id: 1, name: 'Snacks', product_count: 4, is_active: 1 }] }
});
expect(html).toContain('🔒');
});
it('does not render the lock hint when a category has no links', () => {
const html = ejs.render(categoriesTemplate, {
...baseData,
categories: [{ id: 1, name: 'Food', product_count: 0, is_active: 1, country: null, city: null }]
});
expect(html).not.toContain('🔒');
});
});
describe('route source assertions', () => {
it('locations GET passes error and success from query', () => {
expect(locationsSource).toContain('error: req.query.error || \'\'');
expect(locationsSource).toContain('success: req.query.success || \'\'');
});
it('locations DELETE error messages include counts for categories and products', () => {
expect(locationsSource).toContain('(count:+${count.cnt}).+Remove+categories+first.');
expect(locationsSource).toContain('(count:+${productCount.cnt}).+Remove+products+first.');
expect(locationsSource).toContain('Remove+categories+first');
expect(locationsSource).toContain('Remove+products+first');
});
it('locations DELETE success message is set on redirect', () => {
expect(locationsSource).toContain("res.redirect('/locations?success=Location+deleted')");
});
it('categories GET passes error and success from query', () => {
expect(categoriesSource).toContain('error: req.query.error || \'\'');
expect(categoriesSource).toContain('success: req.query.success || \'\'');
});
it('categories DELETE error messages include counts for products and subcategories', () => {
expect(categoriesSource).toContain('(count:+${count.cnt}).+Remove+products+first.');
expect(categoriesSource).toContain('(count:+${subCount.cnt}).+Remove+products+first.');
});
it('categories DELETE and subcategory DELETE success messages are set', () => {
expect(categoriesSource).toContain("res.redirect('/categories?success=Category+deleted')");
expect(categoriesSource).toContain("res.redirect('/categories?success=Subcategory+deleted')");
});
});
});

View File

@@ -21,7 +21,15 @@ router.get('/', asyncHandler(async (req, res) => {
subcategoriesByCategory[s.category_id].push(s);
}
res.render('categories', { title: 'Categories', categories, locations, subcategories, subcategoriesByCategory });
res.render('categories', {
title: 'Categories',
categories,
locations,
subcategories,
subcategoriesByCategory,
error: req.query.error || '',
success: req.query.success || '',
});
}));
router.post('/', asyncHandler(async (req, res) => {
@@ -48,18 +56,18 @@ router.post('/:id/toggle', asyncHandler(async (req, res) => {
router.post('/:id/delete', asyncHandler(async (req, res) => {
const count = await db.getAsync('SELECT COUNT(*) as cnt FROM products WHERE category_id = ?', [req.params.id]);
if (count && count.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+category+with+products');
return res.redirect(`/categories?error=Cannot+delete+category+with+products+(count:+${count.cnt}).+Remove+products+first.`);
}
const subCount = await db.getAsync(
'SELECT COUNT(*) as cnt FROM products WHERE subcategory_id IN (SELECT id FROM subcategories WHERE category_id = ?)',
[req.params.id]
);
if (subCount && subCount.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+category+with+products+in+subcategories');
return res.redirect(`/categories?error=Cannot+delete+category+with+products+in+subcategories+(count:+${subCount.cnt}).+Remove+products+first.`);
}
await db.runAsync('DELETE FROM subcategories WHERE category_id = ?', [req.params.id]);
await db.runAsync('DELETE FROM categories WHERE id = ?', [req.params.id]);
res.redirect('/categories');
res.redirect('/categories?success=Category+deleted');
}));
router.post('/:id/subcategories', asyncHandler(async (req, res) => {
@@ -93,10 +101,10 @@ router.post('/subcategories/:id/delete', asyncHandler(async (req, res) => {
[req.params.id]
);
if (count && count.cnt > 0) {
return res.redirect('/categories?error=Cannot+delete+subcategory+with+products');
return res.redirect(`/categories?error=Cannot+delete+subcategory+with+products+(count:+${count.cnt}).+Remove+products+first.`);
}
await db.runAsync('DELETE FROM subcategories WHERE id = ?', [req.params.id]);
res.redirect('/categories');
res.redirect('/categories?success=Subcategory+deleted');
}));
export default router;

View File

@@ -9,7 +9,12 @@ router.get('/', asyncHandler(async (req, res) => {
(SELECT COUNT(*) FROM categories WHERE location_id = l.id) as category_count,
(SELECT COUNT(*) FROM products WHERE location_id = l.id) as product_count
FROM locations l ORDER BY l.country, l.city, l.district`);
res.render('locations', { title: 'Locations', locations });
res.render('locations', {
title: 'Locations',
locations,
error: req.query.error || '',
success: req.query.success || '',
});
}));
router.post('/', asyncHandler(async (req, res) => {
@@ -49,17 +54,17 @@ router.post('/:id/delete', asyncHandler(async (req, res) => {
[req.params.id]
);
if (count && count.cnt > 0) {
return res.redirect('/locations?error=Cannot+delete+location+with+categories');
return res.redirect(`/locations?error=Cannot+delete+location+with+categories+(count:+${count.cnt}).+Remove+categories+first.`);
}
const productCount = await db.getAsync(
'SELECT COUNT(*) as cnt FROM products WHERE location_id = ?',
[req.params.id]
);
if (productCount && productCount.cnt > 0) {
return res.redirect('/locations?error=Cannot+delete+location+with+products');
return res.redirect(`/locations?error=Cannot+delete+location+with+products+(count:+${productCount.cnt}).+Remove+products+first.`);
}
await db.runAsync('DELETE FROM locations WHERE id = ?', [req.params.id]);
res.redirect('/locations');
res.redirect('/locations?success=Location+deleted');
}));
export default router;

View File

@@ -1,3 +1,15 @@
<% if (error) { %>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<%= error %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<% if (success) { %>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<%= success %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<a href="/catalog" class="btn btn-sm btn-outline-secondary mb-2">« Back to Catalog</a>
<div class="panel panel-icon">
<div class="panel-hdr">
@@ -69,6 +81,9 @@
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button>
</form>
<% if (c.product_count > 0 || (subcategoriesByCategory[c.id] && subcategoriesByCategory[c.id].length > 0)) { %>
<span class="text-warning" title="Blocks deletion">🔒</span>
<% } %>
</td>
</tr>
<% var subs = subcategoriesByCategory[c.id] || []; %>
@@ -108,6 +123,9 @@
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button>
</form>
<% if (s.product_count > 0) { %>
<span class="text-warning" title="Blocks deletion">🔒</span>
<% } %>
</td>
</tr>
<% }); %>

View File

@@ -1,3 +1,15 @@
<% if (error) { %>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<%= error %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<% if (success) { %>
<div class="alert alert-success alert-dismissible fade show" role="alert">
<%= success %>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<% } %>
<a href="/catalog" class="btn btn-sm btn-outline-secondary mb-2">« Back to Catalog</a>
<div class="panel panel-icon mb-3">
<div class="panel-hdr">
@@ -71,6 +83,9 @@
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<button class="btn btn-sm btn-danger">Delete</button>
</form>
<% if (l.category_count > 0 || l.product_count > 0) { %>
<span class="text-warning" title="Blocks deletion">🔒</span>
<% } %>
</td>
</tr>
<% }); %>