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 @@
- Current: v1.2.0 · 2026-07-08
+ Current: v1.2.1 · 2026-07-18
+
v1.2.1 — 2026-07-18
+
+ - refactor Removed deposit amount-selection step; 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
+
+
v1.2.0 — 2026-07-08
- fix Disabled CSRF checks in admin panel for Tor / onion zone compatibility
diff --git a/src/admin/views/product-edit.ejs b/src/admin/views/product-edit.ejs
index f3bd9f0..01652df 100644
--- a/src/admin/views/product-edit.ejs
+++ b/src/admin/views/product-edit.ejs
@@ -65,12 +65,12 @@
-
-
+
+
-
-
+
+
-
-
+
+
-
-
+
+