diff --git a/VERSION.md b/VERSION.md index 14a2f99..f2fba22 100644 --- a/VERSION.md +++ b/VERSION.md @@ -9,10 +9,17 @@ ## Current Version -**v1.2.1** — 2026-07-18 +**v1.2.2** — 2026-08-04 ## Changelog +### v1.2.2 — 2026-08-04 +- **fix**: BUG-01 — disabled locations/categories/subcategories filtered from bot menus (getActiveLocationById, is_active checks) +- **fix**: BUG-02 — graceful handling of disabled entity during purchase flow (location_disabled notice + main-menu redirect, no crash) +- **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) + ### 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 diff --git a/src/__tests__/botBugFixes.test.js b/src/__tests__/botBugFixes.test.js new file mode 100644 index 0000000..a072436 --- /dev/null +++ b/src/__tests__/botBugFixes.test.js @@ -0,0 +1,239 @@ +import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; + +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) + } +})); + +vi.mock('../utils/logger.js', () => ({ + __esModule: true, + default: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + fatal: vi.fn() + } +})); + +const recorded = { + calls: [] +}; + +vi.mock('../config/database.js', () => { + const db = { + allAsync: vi.fn(async (sql, params = []) => { + recorded.calls.push({ method: 'allAsync', sql, params }); + return []; + }), + getAsync: vi.fn(async (sql, params = []) => { + recorded.calls.push({ method: 'getAsync', sql, params }); + return {}; + }), + runAsync: vi.fn(async (sql, params = []) => { + recorded.calls.push({ method: 'runAsync', sql, params }); + return {}; + }) + }; + return { __esModule: true, default: db }; +}); + +const en = { + products: { + location_disabled: 'This location is no longer available.', + back_to_main: '🏠 Main Menu', + error_loading_categories: 'Error loading categories. Please try again.' + } +}; + +function getNestedValue(obj, keyPath) { + return keyPath.split('.').reduce((o, k) => o?.[k], obj); +} + +vi.mock('../i18n/index.js', () => ({ + __esModule: true, + tForUser: (lang) => (key) => getNestedValue(en, key) || key +})); + +vi.mock('../context/bot.js', () => { + const bot = { + deleteMessage: vi.fn().mockResolvedValue({}), + sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }), + sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }), + editMessageText: vi.fn().mockResolvedValue({ message_id: 222 }), + answerCallbackQuery: vi.fn().mockResolvedValue({}) + }; + 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().mockResolvedValue(undefined) + } +})); + +vi.mock('../services/userService.js', () => ({ + __esModule: true, + default: { + getUserByTelegramId: vi.fn().mockResolvedValue({ language: 'en' }) + } +})); + +vi.mock('../services/categoryService.js', () => ({ + __esModule: true, + default: { + getCategoriesByLocationId: vi.fn().mockResolvedValue([]) + } +})); + +import LocationService from '../services/locationService.js'; +import UserProductHandler from '../handlers/userHandlers/userProductHandler.js'; +import bot from '../context/bot.js'; +import userStates from '../context/userStates.js'; +import { resetUserContext } from '../utils/messageUtils.js'; +import * as callbackLock from '../utils/callbackLock.js'; +import CategoryService from '../services/categoryService.js'; + +describe('BUG-01: getActiveLocationById SQL filter', () => { + beforeEach(() => { + recorded.calls = []; + vi.clearAllMocks(); + }); + + it('getActiveLocationById includes is_active = 1 and id = 5', async () => { + await LocationService.getActiveLocationById(5); + const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql; + expect(sql).toContain('is_active = 1'); + expect(sql).toContain('id = ?'); + const params = recorded.calls.find(c => c.method === 'getAsync')?.params; + expect(params).toEqual([5]); + }); + + it('getLocationById does NOT include is_active filter', async () => { + await LocationService.getLocationById(5); + const sql = recorded.calls.find(c => c.method === 'getAsync')?.sql; + expect(sql).not.toContain('is_active'); + expect(sql).toContain('id = ?'); + const params = recorded.calls.find(c => c.method === 'getAsync')?.params; + expect(params).toEqual([5]); + }); +}); + +describe('BUG-02: handleDistrictSelection disabled location path', () => { + let getActiveLocationByIdSpy; + + beforeEach(() => { + vi.clearAllMocks(); + getActiveLocationByIdSpy = vi.spyOn(LocationService, 'getActiveLocationById').mockResolvedValue(null); + CategoryService.getCategoriesByLocationId.mockResolvedValue([]); + }); + + afterEach(() => { + getActiveLocationByIdSpy?.mockRestore(); + }); + + it('shows location_disabled notice and resets state when district is disabled', async () => { + const callbackQuery = { + id: 'cq-disabled', + from: { id: 12345, first_name: 'Test' }, + message: { + chat: { id: 67890 }, + message_id: 111 + }, + data: 'shop_loc_42' + }; + + await expect(UserProductHandler.handleDistrictSelection(callbackQuery)).resolves.toBeUndefined(); + + expect(bot.answerCallbackQuery).toHaveBeenCalledWith('cq-disabled'); + expect(userStates.delete).toHaveBeenCalledWith(67890); + expect(bot.editMessageText).toHaveBeenCalledTimes(1); + const [text, options] = bot.editMessageText.mock.calls[0]; + expect(text).toContain(en.products.location_disabled); + expect(options.chat_id).toBe(67890); + expect(options.message_id).toBe(111); + const keyboard = options.reply_markup.inline_keyboard; + expect(keyboard).toHaveLength(1); + expect(keyboard[0]).toHaveLength(1); + expect(keyboard[0][0].text).toBe(en.products.back_to_main); + expect(keyboard[0][0].callback_data).toBe('shop_start'); + }); +}); + +describe('BUG-03: double-tap lock', () => { + const { tryAcquireLock, LOCK_MS } = callbackLock; + + beforeEach(() => { + vi.useFakeTimers(); + callbackLock.pruneLocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + callbackLock.pruneLocks(); + }); + + it('first acquisition succeeds, immediate second fails, after LOCK_MS succeeds', () => { + const userId = 12345; + + expect(tryAcquireLock(userId)).toBe(true); + expect(tryAcquireLock(userId)).toBe(false); + + vi.advanceTimersByTime(LOCK_MS); + expect(tryAcquireLock(userId)).toBe(true); + }); +}); + +describe('BUG-04: resetUserContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('deletes tracked photo/product/hidden messages and clears state', async () => { + userStates.get.mockResolvedValue({ + photoMessageId: 1, + productMessageId: 2, + hiddenPhotoMessageId: 3 + }); + + await resetUserContext(123); + + expect(bot.deleteMessage).toHaveBeenCalledWith(123, 1); + expect(bot.deleteMessage).toHaveBeenCalledWith(123, 2); + expect(bot.deleteMessage).toHaveBeenCalledWith(123, 3); + expect(userStates.delete).toHaveBeenCalledWith(123); + }); + + it('does not throw when deleteMessage rejects', async () => { + bot.deleteMessage.mockRejectedValueOnce(new Error('cannot delete')); + bot.deleteMessage.mockResolvedValueOnce({}); + bot.deleteMessage.mockRejectedValueOnce(new Error('cannot delete')); + userStates.get.mockResolvedValue({ + photoMessageId: 10, + productMessageId: 11, + hiddenPhotoMessageId: 12 + }); + + await expect(resetUserContext(456)).resolves.toBeUndefined(); + expect(bot.deleteMessage).toHaveBeenCalledTimes(3); + expect(userStates.delete).toHaveBeenCalledWith(456); + }); + + it('handles empty state gracefully', async () => { + userStates.get.mockResolvedValue({}); + + await expect(resetUserContext(789)).resolves.toBeUndefined(); + expect(bot.deleteMessage).not.toHaveBeenCalled(); + expect(userStates.delete).toHaveBeenCalledWith(789); + }); +}); diff --git a/src/__tests__/userProductHandler.test.js b/src/__tests__/userProductHandler.test.js index 742ecaf..9908368 100644 --- a/src/__tests__/userProductHandler.test.js +++ b/src/__tests__/userProductHandler.test.js @@ -12,7 +12,9 @@ const en = { infinite_stock: '∞ Always available', no_description: 'No description provided', no_photo: 'No photo available', - error_loading_product: 'Error loading product details. Please try again.' + error_loading_product: 'Error loading product details. Please try again.', + location_disabled: 'This location is no longer available.', + back_to_main: '🏠 Main Menu' } }; @@ -20,7 +22,8 @@ vi.mock('../context/bot.js', () => { const bot = { deleteMessage: vi.fn(), sendMessage: vi.fn().mockResolvedValue({ message_id: 999 }), - sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }) + sendPhoto: vi.fn().mockResolvedValue({ message_id: 888 }), + answerCallbackQuery: vi.fn().mockResolvedValue({}) }; return { __esModule: true, default: bot, botAvailable: true }; }); @@ -30,7 +33,7 @@ vi.mock('../context/userStates.js', () => ({ default: { get: vi.fn().mockResolvedValue({}), set: vi.fn().mockResolvedValue(undefined), - delete: vi.fn() + delete: vi.fn().mockResolvedValue(undefined) } })); @@ -50,7 +53,8 @@ vi.mock('../services/productService.js', () => ({ __esModule: true, default: { getDetailedProductById: vi.fn(), - getProductById: vi.fn() + getProductById: vi.fn(), + getProductAvailability: vi.fn() } })); @@ -114,9 +118,19 @@ function baseProduct(overrides = {}) { }; } +function baseAvailability(overrides = {}) { + return { + ...baseProduct(), + loc_active: 1, + cat_active: 1, + ...overrides + }; +} + describe('UserProductHandler.handleProductSelection edge cases', () => { beforeEach(() => { vi.clearAllMocks(); + ProductService.getProductAvailability.mockResolvedValue(baseAvailability()); }); it('renders fallback text when description and photo are missing, sends message, does not send photo', async () => { diff --git a/src/admin/views/partials/app-sidebar.ejs b/src/admin/views/partials/app-sidebar.ejs index 1626f1c..d87e6c3 100644 --- a/src/admin/views/partials/app-sidebar.ejs +++ b/src/admin/views/partials/app-sidebar.ejs @@ -28,7 +28,7 @@ - v1.2.1 + v1.2.2 @@ -43,10 +43,10 @@