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:
2026-09-29 17:40:47 +03:00
parent f6e391d588
commit af4850bd6d
10 changed files with 286 additions and 2 deletions

View 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');
});
});