fix: prevent duplicate outreach and follow-ups

This commit is contained in:
jjbrunton committed 2026-08-09 07:48:39 +01:00
1 parent acc610c2bf
commit 5c01d7d9aa
9 files changed
+274 -14

No files matched your search

+53 -3
View File
@@ -2,7 +2,11 @@ import { describe, expect, it, vi } from 'vitest';
import { ConversationAutomationService } from '../src/services/conversation-automation.js';
import type { ConversationState } from '../src/services/conversation-state.js';
function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
function harness(
sellerKeywords: string[] = [],
sellerDomains: string[] = [],
openerSentAt: number | null = 0,
) {
let saved: ConversationState | undefined;
const chat = {
refreshTopChats: vi.fn().mockResolvedValue(undefined),
@@ -14,15 +18,24 @@ function harness(sellerKeywords: string[] = [], sellerDomains: string[] = []) {
get: vi.fn(() => saved),
save: vi.fn((_id: string, state: ConversationState) => { saved = structuredClone(state); }),
};
const openers = {
getByChatRoomId: vi.fn(() => openerSentAt === null
? undefined
: { sentAt: openerSentAt }),
};
const claims = {
tryClaim: vi.fn(() => true),
markSent: vi.fn(() => true),
};
const rateLimiter = {
throttle: vi.fn(async (operation: () => Promise<boolean>) => operation()),
};
const logger = { info: vi.fn() };
const service = new ConversationAutomationService(
chat as never, ai as never, states as never, rateLimiter as never, logger as never,
chat as never, ai as never, states as never, openers as never, claims, rateLimiter as never, logger as never,
false, sellerKeywords, sellerDomains,
);
return { service, chat, ai, states, getSaved: () => saved };
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
}
function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) {
@@ -98,4 +111,41 @@ describe('conversation automation', () => {
expect(chat.sendChatMessage).toHaveBeenCalledTimes(1);
expect(ai.generateMediaAsk).toHaveBeenCalledTimes(1);
});
it('does not reply when the room has no recorded opener', async () => {
const { service, chat, ai, claims } = harness([], [], null);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(claims.tryClaim).not.toHaveBeenCalled();
});
it('does not reply to an inbound event older than the recorded opener', async () => {
const { service, chat, ai, claims } = harness([], [], 4);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
expect(claims.tryClaim).not.toHaveBeenCalled();
});
it('claims an inbound event durably before sending and marks it sent afterwards', async () => {
const { service, chat, claims } = harness();
await service.processConversation(incoming(1));
expect(claims.tryClaim).toHaveBeenCalledWith('room-alice', 'in-1');
expect(claims.tryClaim.mock.invocationCallOrder[0])
.toBeLessThan(chat.sendChatMessage.mock.invocationCallOrder[0]);
expect(claims.markSent).toHaveBeenCalledWith('room-alice', 'in-1');
});
it('does not send when another worker already claimed the inbound event', async () => {
const { service, chat, claims } = harness();
claims.tryClaim.mockReturnValue(false);
expect(await service.processConversation(incoming(1))).toBeNull();
expect(chat.sendChatMessage).not.toHaveBeenCalled();
});
});