// @vitest-environment jsdom /** * Воспроизведение бага «одно сообщение приходит 3 раза». * * Механизм: офлайн-очередь (IndexedDB) общая на устройство, а каждая открытая * вкладка приложения реплеит её НЕЗАВИСИМО (мьютекс _syncLock в useOfflineSync — * модульный, т.е. свой у каждой вкладки). Service Worker по background-sync шлёт * TRIGGER_SYNC ВСЕМ клиентам (sw.js:445-447) → N вкладок одновременно читают * одну и ту же очередь и реплеят один и тот же POST → на сервере N дублей. * * Тест эмулирует две вкладки: два НЕЗАВИСИМЫХ экземпляра модуля useOfflineSync * (отдельные _syncLock, как в разных браузерных вкладках), но ОБЩУЮ очередь * (sharedQueue — аналог общей IndexedDB). */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, act } from '@testing-library/react'; import React from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; // Общая "IndexedDB" двух вкладок — живёт вне моков, переживает resetModules const sharedQueue: Array<{ id: string; method: string; url: string; body: string | null; token: null; orgId: string | null; enqueuedAt: number; }> = []; // Счётчик реальных POST-запросов (реплеев) к серверу let replayedPosts: Array<{ url: string; body: string | null }> = []; vi.mock('@/lib/offlineQueue', () => ({ getAll: vi.fn(async () => [...sharedQueue]), remove: vi.fn(async (id: string) => { const idx = sharedQueue.findIndex((i) => i.id === id); if (idx >= 0) sharedQueue.splice(idx, 1); }), count: vi.fn(async () => sharedQueue.length), markAsConflict: vi.fn(async () => {}), clearConflictStatus: vi.fn(async () => {}), updateBody: vi.fn(async () => {}), })); vi.mock('@/lib/attachmentQueue', () => ({ getAllAttachments: vi.fn(async () => []), countAttachments: vi.fn(async () => 0), getAttachmentById: vi.fn(), removeAttachment: vi.fn(async () => {}), updateAttachment: vi.fn(async () => {}), markAttachmentError: vi.fn(async () => {}), })); vi.mock('@/lib/syncEngine', () => ({ performDeltaSync: vi.fn(async () => {}), performInitialSync: vi.fn(async () => {}), checkServerReachable: vi.fn(async () => true), })); vi.mock('@/lib/tasksCache', () => ({ saveInbox: vi.fn(async () => {}), pruneStaleInboxTasks: vi.fn(async () => {}), })); vi.mock('@/lib/sseManager', () => ({ sseManager: { setUser: vi.fn(), on: vi.fn(() => () => {}), onConnState: vi.fn(() => () => {}), reconnect: vi.fn(), }, })); function makeWrapper() { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); return ({ children }: { children: React.ReactNode }) => React.createElement(QueryClientProvider, { client }, children); } async function importFreshHook() { vi.resetModules(); // новый экземпляр модуля = новая «вкладка» со своим _syncLock return (await import('@/hooks/useOfflineSync')).useOfflineSync; } describe('multi-tab offline queue replay race', () => { beforeEach(() => { sharedQueue.length = 0; replayedPosts = []; // По умолчанию Web Locks НЕТ (fallback-путь) — гонка воспроизводится delete (navigator as unknown as { locks?: unknown }).locks; vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); if ((init?.method ?? 'GET') === 'POST') { replayedPosts.push({ url, body: init?.body ? String(init.body) : null }); } return new Response( JSON.stringify({ success: true, tasks: [] }), { status: 200, headers: { 'Content-Type': 'application/json' } }, ); })); }); it('без Web Locks две вкладки реплеят одну запись дважды (исходная гонка)', async () => { const useOfflineSyncA = await importFreshHook(); const useOfflineSyncB = await importFreshHook(); // Одна запись в общей очереди (сообщение, которое не ушло при офлайне) sharedQueue.push({ id: 'q1', method: 'POST', url: '/api/tasks/1988/messages', body: JSON.stringify({ message: 'тестовое сообщение' }), token: null, orgId: '1', enqueuedAt: Date.now(), }); const tabA = renderHook(() => useOfflineSyncA(1), { wrapper: makeWrapper() }); const tabB = renderHook(() => useOfflineSyncB(1), { wrapper: makeWrapper() }); // Обе «вкладки» одновременно получают TRIGGER_SYNC (background-sync от SW // рассылается всем клиентам) и реплеят очередь await act(async () => { await Promise.all([ tabA.result.current.sync(), tabB.result.current.sync(), ]); }); const messagePosts = replayedPosts.filter((p) => p.url === '/api/tasks/1988/messages'); console.log(`[race-test] без Web Locks: POST выполнен ${messagePosts.length} раз(а) для 1 записи`); // Исходная гонка (документация бага): без кросс-вкладочной координации // одна запись уходит по разу на каждую вкладку. expect(messagePosts.length).toBe(2); }); it('с Web Locks (кросс-вкладочный мьютекс) запись реплеится ровно один раз', async () => { // Эмуляция navigator.locks: сериализация запросов по имени (как у браузера) let lockChain: Promise = Promise.resolve(); const mockLocks = { request: async (_name: string, cb: () => Promise) => { const prev = lockChain; let release!: () => void; lockChain = new Promise((r) => { release = r; }); await prev; try { return await cb(); } finally { release(); } }, }; Object.defineProperty(navigator, 'locks', { value: mockLocks, configurable: true }); const useOfflineSyncA = await importFreshHook(); const useOfflineSyncB = await importFreshHook(); sharedQueue.push({ id: 'q1', method: 'POST', url: '/api/tasks/1988/messages', body: JSON.stringify({ message: 'тестовое сообщение' }), token: null, orgId: '1', enqueuedAt: Date.now(), }); const tabA = renderHook(() => useOfflineSyncA(1), { wrapper: makeWrapper() }); const tabB = renderHook(() => useOfflineSyncB(1), { wrapper: makeWrapper() }); await act(async () => { await Promise.all([ tabA.result.current.sync(), tabB.result.current.sync(), ]); }); const messagePosts = replayedPosts.filter((p) => p.url === '/api/tasks/1988/messages'); console.log(`[race-test] с Web Locks: POST выполнен ${messagePosts.length} раз(а) для 1 записи`); // ФИКС: первая вкладка под локом реплеит и удаляет запись, вторая видит // пустую очередь — ровно один POST. expect(messagePosts.length).toBe(1); }); });