Первопричина дублей (3 одинаковых сообщения в задаче 1988): живой POST сорвался → сообщение попало в общую IndexedDB-очередь → background-sync разослал TRIGGER_SYNC всем вкладкам → каждая вкладка реплейнула одну и ту же запись (мьютекс _syncLock модульный, кросс-вкладочной координации нет). Гонка воспроизведена тестом tests/offline-queue-multitab-race.test.tsx (1 запись → 2 POST для двух вкладок). Вариант C — серверная идемпотентность: - task_messages.client_message_id + partial unique index (миграция 0086); - sendTaskMessage: повтор с тем же clientMessageId возвращает существующее сообщение БЕЗ insert и БЕЗ side-эффектов (уведомления/SSE/вебхуки не дублируются); гонка insert'ов ловится по 23505 → fallback на select; - клиент TaskChat шлёт crypto.randomUUID() в каждом сообщении; при офлайн-постановке тело с ключом сохраняется, все реплеи идут с одним ключом; - attachment-реплей: clientMessageId = id записи очереди (стабилен между реплеями); - юнит-тесты tests/task-message-idempotency.test.ts (3: быстрый путь, гонка 23505, проброс прочих ошибок).
71 lines
3.1 KiB
TypeScript
71 lines
3.1 KiB
TypeScript
/**
|
||
* Серверная идемпотентность сообщений (вариант C, миграция 0086):
|
||
* повторная отправка с тем же clientMessageId НЕ создаёт дубль —
|
||
* sendTaskMessage возвращает уже существующее сообщение без side-эффектов.
|
||
*
|
||
* Два сценария:
|
||
* 1. Запись уже есть (быстрый путь: select до insert).
|
||
* 2. Гонка: параллельный insert упал на unique index (23505) — fallback на select.
|
||
*/
|
||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||
|
||
const mockStorage = vi.hoisted(() => ({
|
||
getTaskMessageIdByClientMessageId: vi.fn(),
|
||
getTaskMessage: vi.fn(),
|
||
createTaskMessage: vi.fn(),
|
||
}));
|
||
|
||
vi.mock('../server/storage', () => ({ storage: mockStorage }));
|
||
|
||
import { sendTaskMessage } from '../server/services/task-message.service';
|
||
|
||
const task = { id: 1988, formId: 14 } as never;
|
||
const user = { id: 1, firstName: 'Ильяс', lastName: 'Султанов' } as never;
|
||
const existingMessage = { id: 994, taskId: 1988, message: 'тест', clientMessageId: 'uuid-1' };
|
||
|
||
const baseParams = {
|
||
task,
|
||
user,
|
||
organizationId: 1,
|
||
message: 'тест',
|
||
clientMessageId: 'uuid-1',
|
||
};
|
||
|
||
describe('sendTaskMessage — идемпотентность по clientMessageId', () => {
|
||
beforeEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
it('повтор с тем же ключом: возвращает существующее сообщение, insert НЕ вызывается', async () => {
|
||
mockStorage.getTaskMessageIdByClientMessageId.mockResolvedValue(994);
|
||
mockStorage.getTaskMessage.mockResolvedValue(existingMessage);
|
||
|
||
const result = await sendTaskMessage(baseParams);
|
||
|
||
expect(result).toBe(existingMessage);
|
||
expect(mockStorage.createTaskMessage).not.toHaveBeenCalled();
|
||
expect(mockStorage.getTaskMessageIdByClientMessageId).toHaveBeenCalledWith(1988, 'uuid-1');
|
||
});
|
||
|
||
it('гонка: unique violation (23505) на insert → fallback возвращает существующее', async () => {
|
||
mockStorage.getTaskMessageIdByClientMessageId
|
||
.mockResolvedValueOnce(null) // select до insert — ещё пусто
|
||
.mockResolvedValueOnce(994); // select после unique-конфликта — уже вставлено параллельным запросом
|
||
const pgError = Object.assign(new Error('duplicate key value violates unique constraint'), { code: '23505' });
|
||
mockStorage.createTaskMessage.mockRejectedValue(pgError);
|
||
mockStorage.getTaskMessage.mockResolvedValue(existingMessage);
|
||
|
||
const result = await sendTaskMessage(baseParams);
|
||
|
||
expect(result).toBe(existingMessage);
|
||
expect(mockStorage.createTaskMessage).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('другие ошибки insert пробрасываются наружу', async () => {
|
||
mockStorage.getTaskMessageIdByClientMessageId.mockResolvedValue(null);
|
||
mockStorage.createTaskMessage.mockRejectedValue(new Error('connection refused'));
|
||
|
||
await expect(sendTaskMessage(baseParams)).rejects.toThrow('connection refused');
|
||
});
|
||
});
|