Клиентская часть гонки (вариант B): Web Locks API (navigator.locks) — кросс-вкладочный мьютекс iistwin-offline-sync-replay на replay-проход в useOfflineSync: первая вкладка под локом реплеит и удаляет записи, остальные видят пустую очередь. Fallback для сред без Web Locks — старое поведение (дубли гасит серверная идемпотентность). Race-тест: без locks → 2 POST, с locks → 1 POST для одной записи очереди. Мессенджер (conv_messages) — та же защита, что у task_messages: - conversation_messages.client_message_id + partial unique (миграция 0087); - POST /api/messenger/conversations/:id/messages: повтор с тем же clientMessageId → существующее сообщение без insert и без SSE/уведомлений, гонка insert'ов ловится по 23505; - useChatController шлёт crypto.randomUUID(), attachment-реплей мессенджера тоже использует id записи очереди.
186 lines
7.5 KiB
TypeScript
186 lines
7.5 KiB
TypeScript
// @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<void> = Promise.resolve();
|
||
const mockLocks = {
|
||
request: async (_name: string, cb: () => Promise<unknown>) => {
|
||
const prev = lockChain;
|
||
let release!: () => void;
|
||
lockChain = new Promise<void>((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);
|
||
});
|
||
});
|