152 lines
6.4 KiB
TypeScript
152 lines
6.4 KiB
TypeScript
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[] = [],
|
|
openerSentAt: number | null = 0,
|
|
) {
|
|
let saved: ConversationState | undefined;
|
|
const chat = {
|
|
refreshTopChats: vi.fn().mockResolvedValue(undefined),
|
|
getCachedConversations: vi.fn().mockResolvedValue([]),
|
|
sendChatMessage: vi.fn().mockResolvedValue(true),
|
|
};
|
|
const ai = { generateMediaAsk: vi.fn().mockResolvedValue('Would you be comfortable sharing a selfie?') };
|
|
const states = {
|
|
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, openers as never, claims, rateLimiter as never, logger as never,
|
|
false, sellerKeywords, sellerDomains,
|
|
);
|
|
return { service, chat, ai, states, openers, claims, getSaved: () => saved };
|
|
}
|
|
|
|
function incoming(turn: number, text = `reply ${turn}`, media?: Array<{ originalUrl: string; localPath: string; fileName: string }>) {
|
|
return {
|
|
username: 'alice', roomName: 'alice', roomId: 'room-alice', url: '/room/room-alice',
|
|
lastMessageTime: turn, lastMessageSnippet: text, isFromUs: false, unread: true,
|
|
messages: [
|
|
{ eventId: `out-${turn}`, sender: 'me', text: 'hello', timestamp: turn * 2, isSelf: true },
|
|
{ eventId: `in-${turn}`, sender: 'alice', text, timestamp: turn * 2 + 1, isSelf: false, media },
|
|
],
|
|
};
|
|
}
|
|
|
|
describe('conversation automation', () => {
|
|
it('sends once per inbound turn and flags human handoff after the third send', async () => {
|
|
const { service, chat, getSaved } = harness();
|
|
|
|
expect((await service.processConversation(incoming(1)))?.remainingTurns).toBe(2);
|
|
expect((await service.processConversation(incoming(2)))?.remainingTurns).toBe(1);
|
|
const third = await service.processConversation(incoming(3));
|
|
|
|
expect(third).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
|
expect(chat.sendChatMessage).toHaveBeenCalledTimes(3);
|
|
expect(getSaved()?.automation).toMatchObject({ turnsUsed: 3, status: 'human-required' });
|
|
expect(getSaved()?.automation.aiMessageFingerprints).toHaveLength(3);
|
|
expect(await service.processConversation(incoming(3))).toBeNull();
|
|
});
|
|
|
|
it('stops without sending when the user declines images', async () => {
|
|
const { service, chat, getSaved } = harness();
|
|
const result = await service.processConversation(incoming(1, "No thanks, I don't send photos."));
|
|
|
|
expect(result?.kind).toBe('boundary');
|
|
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
|
expect(getSaved()?.automation.status).toBe('boundary');
|
|
});
|
|
|
|
it('flags a potential upsell for human handoff without generating a reply', async () => {
|
|
const { service, chat, ai, getSaved } = harness(['onlyfans'], ['fansly.com']);
|
|
const result = await service.processConversation(incoming(1, 'My Fansly is https://www.fansly.com/example'));
|
|
|
|
expect(result).toMatchObject({ kind: 'human-required', remainingTurns: 0 });
|
|
expect(ai.generateMediaAsk).not.toHaveBeenCalled();
|
|
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
|
expect(getSaved()?.automation).toMatchObject({
|
|
status: 'human-required',
|
|
handoffReason: 'Potential upsell detected (domain: fansly.com).',
|
|
});
|
|
});
|
|
|
|
it('completes when an inbound image is received', async () => {
|
|
const { service, chat, getSaved } = harness();
|
|
const result = await service.processConversation(incoming(1, '[Image]', [{
|
|
originalUrl: 'mxc://image', localPath: 'image.jpg', fileName: 'image.jpg',
|
|
}]));
|
|
|
|
expect(result?.kind).toBe('image-received');
|
|
expect(chat.sendChatMessage).not.toHaveBeenCalled();
|
|
expect(getSaved()?.automation.status).toBe('image-received');
|
|
});
|
|
|
|
it('does not generate or send after a human has overridden the thread', async () => {
|
|
const { service, chat, ai, states } = harness();
|
|
const conversation = incoming(1);
|
|
await service.processConversation(conversation);
|
|
const state = states.save.mock.calls.at(-1)?.[1] as ConversationState;
|
|
state.automation.status = 'user-overridden';
|
|
states.save('room-alice', state);
|
|
|
|
const result = await service.processConversation(incoming(2));
|
|
|
|
expect(result).toBeNull();
|
|
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();
|
|
});
|
|
});
|