Files
iistwin/tests/task-message-idempotency.test.ts
Ильяс Султанов af4850bd6d 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,
  проброс прочих ошибок).
2026-09-29 17:40:47 +03:00

71 lines
3.1 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.

/**
* Серверная идемпотентность сообщений (вариант 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');
});
});