From 776d0e8552c97b8cfcafc466455380d401e2e0b7 Mon Sep 17 00:00:00 2001 From: NW Date: Sat, 18 Jul 2026 14:28:26 +0100 Subject: [PATCH] fix(bot): remove redundant deposit amount step, add Visa/MC label, Mercuryo auth note, enforce product completeness - Remove deposit amount-selection step; deposit_wallet_ now routes directly to instruction - Add VISA / Mastercard text to Mercuryo card button (i18n en/es/de) - Add deposit_important5: Mercuryo 1-payment-without-auth + top-up-reserve warning - Admin validation: description + photo required on product create/update (products.js, catalogProducts.js) - Cleanup orphan uploaded files on validation failure (catalogProducts.js) - Bot guards: fallback to products.no_description / products.no_photo for incomplete products - Add vitest + 7 purchase edge-case tests (src/__tests__/userProductHandler.test.js) - Bump version to v1.2.1 --- .gitignore | 3 + VERSION.md | 11 +- package.json | 5 +- src/__tests__/userProductHandler.test.js | 223 ++++++++++++++++++ src/admin/routes/catalog.js | 7 +- src/admin/routes/catalogProducts.js | 26 +- src/admin/routes/products.js | 20 +- src/admin/views/partials/app-sidebar.ejs | 11 +- src/admin/views/product-edit.ejs | 8 +- src/admin/views/products.ejs | 8 +- .../userHandlers/userProductHandler.js | 7 +- .../userHandlers/wallet/depositHandler.js | 62 +---- src/handlers/userHandlers/wallet/index.js | 1 - src/i18n/locales/de.json | 17 +- src/i18n/locales/en.json | 17 +- src/i18n/locales/es.json | 17 +- src/router/routes.js | 4 - 17 files changed, 344 insertions(+), 103 deletions(-) create mode 100644 src/__tests__/userProductHandler.test.js diff --git a/.gitignore b/.gitignore index 2d89965..4440ddd 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,6 @@ db/*.db-shm /*.mjs /*.mjs + +# Production backups (contain secrets + DB snapshots — never commit) +production-backup/ diff --git a/VERSION.md b/VERSION.md index 0a059fe..5aa3f1c 100644 --- a/VERSION.md +++ b/VERSION.md @@ -9,10 +9,19 @@ ## Current Version -**v1.2.0** — 2026-07-08 +**v1.2.1** — 2026-07-18 ## Changelog +### v1.2.1 — 2026-07-18 +- **refactor**: Removed deposit amount-selection step (redundant); deposit_wallet_ now goes directly to Mercuryo instructions +- **feat**: Updated Mercuryo button text to include VISA/Mastercard branding in all locales +- **feat**: Added deposit_important5 note about Mercuryo authorization limits and top-up reserve +- **fix**: Enforced description + photo required in admin product create/update routes (products.js, catalogProducts.js) +- **fix**: Added defensive guards in bot purchase display — description fallback and no-photo placeholder (userProductHandler.js) +- **feat**: Added i18n keys `products.no_description` and `products.no_photo` in en/es/de +- **fix**: Marked description and photo fields as required in admin forms (product-edit.ejs, products.ejs, catalog modal) + ### v1.2.0 — 2026-07-08 - **fix**: Disabled CSRF checks in admin panel for Tor / onion zone compatibility - **fix**: Fixed "Invalid wallet type" error in Telegram bot purchase flow (`main`/`bonus` types added to validator) diff --git a/package.json b/package.json index 1b7a2ae..8e0b509 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,8 @@ "version": "1.0.0", "type": "module", "scripts": { + "test": "vitest run", + "test:watch": "vitest", "start": "node src/index.js", "dev": "nodemon src/index.js" }, @@ -31,6 +33,7 @@ }, "devDependencies": { "nodemon": "^3.0.2", - "playwright": "^1.61.1" + "playwright": "^1.61.1", + "vitest": "^2.1.9" } } diff --git a/src/__tests__/userProductHandler.test.js b/src/__tests__/userProductHandler.test.js new file mode 100644 index 0000000..742ecaf --- /dev/null +++ b/src/__tests__/userProductHandler.test.js @@ -0,0 +1,223 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import UserProductHandler from '../handlers/userHandlers/userProductHandler.js'; + +const en = { + products: { + product_price: '💰 Price', + product_description: '📝 Description', + product_available: '📦 Available', + product_category: 'Category', + buy_now: '🛒 Buy Now', + back: '« Back', + infinite_stock: '∞ Always available', + no_description: 'No description provided', + no_photo: 'No photo available', + error_loading_product: 'Error loading product details. Please try again.' + } +}; + +vi.mock('../context/bot.js', () => { + const bot = { + deleteMessage: vi.fn(), + sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }), + sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }) + }; + return { __esModule: true, default: bot, botAvailable: true }; +}); + +vi.mock('../context/userStates.js', () => ({ + __esModule: true, + default: { + get: vi.fn().mockResolvedValue({}), + set: vi.fn().mockResolvedValue(undefined), + delete: vi.fn() + } +})); + +vi.mock('../services/userService.js', () => ({ + __esModule: true, + default: { + getUserByTelegramId: vi.fn().mockResolvedValue({ language: 'en' }) + } +})); + +vi.mock('../i18n/index.js', () => ({ + __esModule: true, + tForUser: (lang) => (key) => getNestedValue(en, key) || key +})); + +vi.mock('../services/productService.js', () => ({ + __esModule: true, + default: { + getDetailedProductById: vi.fn(), + getProductById: vi.fn() + } +})); + +vi.mock('../utils/logger.js', () => ({ + __esModule: true, + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn() + } +})); + +vi.mock('../config/config.js', () => ({ + __esModule: true, + default: { + BOT_TOKEN: 'test-token', + ADMIN_IDS: ['123456789'], + SUPER_ADMIN_IDS: ['123456789'], + SUPPORT_LINK: 'https://t.me/support', + DEFAULT_LANGUAGE: 'en', + ENCRYPTION_KEY: 'x'.repeat(64) + } +})); + +function getNestedValue(obj, keyPath) { + return keyPath.split('.').reduce((o, k) => o?.[k], obj); +} + +import bot from '../context/bot.js'; +import userStates from '../context/userStates.js'; +import ProductService from '../services/productService.js'; + +function makeCallbackQuery(productId, overrides = {}) { + return { + id: 'callback-id', + from: { id: 12345, first_name: 'Test' }, + message: { + chat: { id: 67890 }, + message_id: 111 + }, + data: `shop_product_${productId}`, + ...overrides + }; +} + +function baseProduct(overrides = {}) { + return { + id: 1, + name: 'Test Product', + price: 10, + description: 'A nice product', + quantity_in_stock: 5, + photo_url: 'http://example.com/photo.jpg', + is_mono: 0, + category_name: 'Test Category', + category_id: 2, + location_id: 3, + ...overrides + }; +} + +describe('UserProductHandler.handleProductSelection edge cases', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders fallback text when description and photo are missing, sends message, does not send photo', async () => { + ProductService.getDetailedProductById.mockResolvedValue( + baseProduct({ description: '', photo_url: '' }) + ); + + await expect( + UserProductHandler.handleProductSelection(makeCallbackQuery(1)) + ).resolves.toBeUndefined(); + + expect(bot.deleteMessage).toHaveBeenCalledWith(67890, 111); + expect(bot.sendPhoto).not.toHaveBeenCalled(); + expect(bot.sendMessage).toHaveBeenCalledTimes(1); + const sentText = bot.sendMessage.mock.calls[0][1]; + expect(sentText).toContain(en.products.no_description); + expect(sentText).toContain(en.products.no_photo); + }); + + it('renders no-photo line and does not call sendPhoto when photo_url is missing', async () => { + ProductService.getDetailedProductById.mockResolvedValue( + baseProduct({ photo_url: '' }) + ); + + await expect( + UserProductHandler.handleProductSelection(makeCallbackQuery(1)) + ).resolves.toBeUndefined(); + + expect(bot.sendPhoto).not.toHaveBeenCalled(); + const sentText = bot.sendMessage.mock.calls[0][1]; + expect(sentText).toContain(en.products.no_photo); + expect(sentText).toContain('A nice product'); + }); + + it('renders description fallback and calls sendPhoto when description is missing', async () => { + ProductService.getDetailedProductById.mockResolvedValue( + baseProduct({ description: '' }) + ); + + await expect( + UserProductHandler.handleProductSelection(makeCallbackQuery(1)) + ).resolves.toBeUndefined(); + + expect(bot.sendPhoto).toHaveBeenCalledWith(67890, 'http://example.com/photo.jpg', { caption: 'Public photo' }); + const sentText = bot.sendMessage.mock.calls[0][1]; + expect(sentText).toContain(en.products.no_description); + expect(sentText).not.toContain(en.products.no_photo); + }); + + it('takes normal path with both fields present, calls sendPhoto, omits fallback text', async () => { + ProductService.getDetailedProductById.mockResolvedValue(baseProduct()); + + await expect( + UserProductHandler.handleProductSelection(makeCallbackQuery(1)) + ).resolves.toBeUndefined(); + + expect(bot.sendPhoto).toHaveBeenCalledWith(67890, 'http://example.com/photo.jpg', { caption: 'Public photo' }); + const sentText = bot.sendMessage.mock.calls[0][1]; + expect(sentText).toContain('A nice product'); + expect(sentText).not.toContain(en.products.no_description); + expect(sentText).not.toContain(en.products.no_photo); + }); + + it('does not crash when ProductService returns null, sends error message', async () => { + ProductService.getDetailedProductById.mockResolvedValue(null); + + await expect( + UserProductHandler.handleProductSelection(makeCallbackQuery(1)) + ).resolves.toBeUndefined(); + + expect(bot.sendMessage).toHaveBeenCalledWith(67890, en.products.error_loading_product); + expect(bot.sendPhoto).not.toHaveBeenCalled(); + }); + + it('renders infinite_stock line for mono product with missing description', async () => { + ProductService.getDetailedProductById.mockResolvedValue( + baseProduct({ is_mono: 1, description: '', photo_url: '' }) + ); + + await expect( + UserProductHandler.handleProductSelection(makeCallbackQuery(1)) + ).resolves.toBeUndefined(); + + const sentText = bot.sendMessage.mock.calls[0][1]; + expect(sentText).toContain(en.products.no_description); + expect(sentText).toContain(en.products.infinite_stock); + expect(sentText).toContain(en.products.no_photo); + expect(bot.sendPhoto).not.toHaveBeenCalled(); + }); + + it('renders non-mono product with quantity_in_stock=0 without crashing', async () => { + ProductService.getDetailedProductById.mockResolvedValue( + baseProduct({ quantity_in_stock: 0, photo_url: '' }) + ); + + await expect( + UserProductHandler.handleProductSelection(makeCallbackQuery(1)) + ).resolves.toBeUndefined(); + + const sentText = bot.sendMessage.mock.calls[0][1]; + expect(sentText).toContain('0 pcs'); + expect(bot.sendPhoto).not.toHaveBeenCalled(); + }); +}); diff --git a/src/admin/routes/catalog.js b/src/admin/routes/catalog.js index 5f54409..bbbb866 100644 --- a/src/admin/routes/catalog.js +++ b/src/admin/routes/catalog.js @@ -230,13 +230,13 @@ function buildProductFormHtml(action, catOptions, locations) {
- - + +

-
Public Photo
+
Public Photo *
@@ -245,6 +245,7 @@ function buildProductFormHtml(action, catOptions, locations) {
+ Photo is required — upload a file or paste a URL.
diff --git a/src/admin/routes/catalogProducts.js b/src/admin/routes/catalogProducts.js index 2bacaa5..240615d 100644 --- a/src/admin/routes/catalogProducts.js +++ b/src/admin/routes/catalogProducts.js @@ -3,10 +3,18 @@ import multer from 'multer'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import crypto from 'crypto'; +import fs from 'fs'; import db from '../../config/database.js'; import { asyncHandler } from '../errorHandler.js'; import { validateCsrfFromBody } from '../csrf.js'; +function cleanupUploadedFiles(req) { + for (const field of ['photo_file', 'hidden_photo_file']) { + const p = req.files?.[field]?.[0]?.path; + if (p) fs.unlink(p, () => {}); + } +} + const __dirname = dirname(fileURLToPath(import.meta.url)); const uploadsDir = join(__dirname, '..', '..', '..', 'uploads'); const storage = multer.diskStorage({ @@ -34,10 +42,13 @@ router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_ if (!validateCsrfFromBody(req, res)) return; const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url, hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id, is_mono } = req.body; - if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error'); + if (!name || !price || !category_id) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error'); } const priceNum = parseFloat(price); - if (isNaN(priceNum) || priceNum <= 0) return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error'); + if (isNaN(priceNum) || priceNum <= 0) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error'); } + const desc = (description || '').trim(); + if (!desc) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Description+is+required&msg_type=error'); } const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || ''); + if (!pu.trim()) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Photo+is+required&msg_type=error'); } const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || ''); const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null; const monoFlag = is_mono === '1' ? 1 : 0; @@ -45,7 +56,7 @@ router.post('/products', upload.fields([{ name: 'photo_file' }, { name: 'hidden_ await db.runAsync(`INSERT INTO products (name,price,quantity_in_stock,description,photo_url,hidden_photo_url, hidden_coordinates,hidden_description,private_data,category_id,subcategory_id,location_id,is_mono) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, - [name.trim(), priceNum, stockQty, description || '', pu, hu, + [name.trim(), priceNum, stockQty, desc, pu, hu, hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId, monoFlag]); res.redirect('/catalog?msg=Product+added&msg_type=success'); })); @@ -54,17 +65,20 @@ router.post('/products/:id/edit', upload.fields([{ name: 'photo_file' }, { name: if (!validateCsrfFromBody(req, res)) return; const { name, price, quantity_in_stock, description, photo_url, hidden_photo_url, hidden_coordinates, hidden_description, private_data, category_id, subcategory_id, location_id, is_mono } = req.body; - if (!name || !price || !category_id) return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error'); + if (!name || !price || !category_id) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Name+price+category+required&msg_type=error'); } const priceNum = parseFloat(price); - if (isNaN(priceNum) || priceNum <= 0) return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error'); + if (isNaN(priceNum) || priceNum <= 0) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Price+must+be+greater+than+0&msg_type=error'); } + const desc = (description || '').trim(); + if (!desc) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Description+is+required&msg_type=error'); } const pu = req.files?.photo_file?.[0] ? `/uploads/${req.files.photo_file[0].filename}` : (photo_url || ''); + if (!pu.trim()) { cleanupUploadedFiles(req); return res.redirect('/catalog?msg=Photo+is+required&msg_type=error'); } const hu = req.files?.hidden_photo_file?.[0] ? `/uploads/${req.files.hidden_photo_file[0].filename}` : (hidden_photo_url || ''); const locId = location_id || (await db.getAsync('SELECT location_id FROM categories WHERE id=?', [category_id]))?.location_id || null; const monoFlag = is_mono === '1' ? 1 : 0; const stockQty = monoFlag ? 999999 : (parseInt(quantity_in_stock, 10) || 0); await db.runAsync(`UPDATE products SET name=?,price=?,quantity_in_stock=?,description=?,photo_url=?,hidden_photo_url=?, hidden_coordinates=?,hidden_description=?,private_data=?,category_id=?,subcategory_id=?,location_id=?,is_mono=? WHERE id=?`, - [name.trim(), priceNum, stockQty, description || '', pu, hu, + [name.trim(), priceNum, stockQty, desc, pu, hu, hidden_coordinates || '', hidden_description || '', private_data || '', category_id, subcategory_id || null, locId, monoFlag, req.params.id]); res.redirect('/catalog?msg=Product+updated&msg_type=success'); })); diff --git a/src/admin/routes/products.js b/src/admin/routes/products.js index 8ada844..458f71c 100644 --- a/src/admin/routes/products.js +++ b/src/admin/routes/products.js @@ -26,12 +26,20 @@ router.post('/', asyncHandler(async (req, res) => { if (isNaN(priceNum) || priceNum <= 0) { return res.redirect('/products?error=Price+must+be+greater+than+0'); } + const desc = (description || '').trim(); + if (!desc) { + return res.redirect('/products?error=Description+is+required'); + } + const photo = (photo_url || '').trim(); + if (!photo) { + return res.redirect('/products?error=Photo+is+required'); + } const monoFlag = is_mono === '1' ? 1 : 0; const stockQty = monoFlag ? 999999 : (quantity_in_stock || 0); await db.runAsync( `INSERT INTO products (name, price, quantity_in_stock, description, photo_url, category_id, subcategory_id, location_id, is_mono) VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT location_id FROM categories WHERE id = ?), ?)`, - [name, priceNum, stockQty, description || '', photo_url || '', category_id, subcategory_id || null, category_id, monoFlag] + [name, priceNum, stockQty, desc, photo, category_id, subcategory_id || null, category_id, monoFlag] ); res.redirect('/products'); })); @@ -56,12 +64,20 @@ router.post('/:id/update', asyncHandler(async (req, res) => { if (isNaN(priceNum) || priceNum <= 0) { return res.redirect('/products?error=Price+must+be+greater+than+0'); } + const desc = (description || '').trim(); + if (!desc) { + return res.redirect('/products?error=Description+is+required'); + } + const photo = (photo_url || '').trim(); + if (!photo) { + return res.redirect('/products?error=Photo+is+required'); + } const monoFlag = is_mono === '1' ? 1 : 0; const stockQty = monoFlag ? 999999 : (quantity_in_stock || 0); await db.runAsync( `UPDATE products SET name=?, price=?, quantity_in_stock=?, description=?, photo_url=?, category_id=?, subcategory_id=?, location_id=?, is_mono=? WHERE id=?`, - [name, priceNum, stockQty, description || '', photo_url || '', category_id, subcategory_id || null, location_id || null, monoFlag, req.params.id] + [name, priceNum, stockQty, desc, photo, category_id, subcategory_id || null, location_id || null, monoFlag, req.params.id] ); res.redirect('/products'); })); diff --git a/src/admin/views/partials/app-sidebar.ejs b/src/admin/views/partials/app-sidebar.ejs index de5c57b..1626f1c 100644 --- a/src/admin/views/partials/app-sidebar.ejs +++ b/src/admin/views/partials/app-sidebar.ejs @@ -28,7 +28,7 @@ - v1.2.0 + v1.2.1
@@ -43,9 +43,16 @@
- - + +
- - + +
- - + +
- - + +