Files
iistwin/tests/offline-queue-multitab-race.test.tsx
Ильяс Султанов 9827b5df33 fix(chat): cross-tab lock реплея офлайн-очереди + идемпотентность мессенджера (conv_messages)
Клиентская часть гонки (вариант 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 записи очереди.
2026-09-29 20:31:59 +03:00

186 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// @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);
});
});