fix(chat): идемпотентность сообщений по clientMessageId — защита от дублей при повторной отправке
Первопричина дублей (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, проброс прочих ошибок).
This commit is contained in:
70
tests/task-message-idempotency.test.ts
Normal file
70
tests/task-message-idempotency.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Серверная идемпотентность сообщений (вариант 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user