diff --git a/VERSION.md b/VERSION.md index f2fba22..661234e 100644 --- a/VERSION.md +++ b/VERSION.md @@ -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 diff --git a/src/__tests__/adminDeleteFeedback.test.js b/src/__tests__/adminDeleteFeedback.test.js new file mode 100644 index 0000000..ab98822 --- /dev/null +++ b/src/__tests__/adminDeleteFeedback.test.js @@ -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')"); + }); + }); +}); diff --git a/src/admin/routes/categories.js b/src/admin/routes/categories.js index 5ad0587..7653f8b 100644 --- a/src/admin/routes/categories.js +++ b/src/admin/routes/categories.js @@ -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; diff --git a/src/admin/routes/locations.js b/src/admin/routes/locations.js index 53709df..0ed1778 100644 --- a/src/admin/routes/locations.js +++ b/src/admin/routes/locations.js @@ -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; diff --git a/src/admin/views/categories.ejs b/src/admin/views/categories.ejs index 080bc69..0116a84 100644 --- a/src/admin/views/categories.ejs +++ b/src/admin/views/categories.ejs @@ -1,3 +1,15 @@ +<% if (error) { %> +