mirror of
https://github.com/hexastack/hexabot
synced 2025-06-26 18:27:28 +00:00
test: consolidate unit tests
This commit is contained in:
parent
d88959cea2
commit
b973c466d7
@ -51,6 +51,8 @@ import { SettingService } from '@/setting/services/setting.service';
|
||||
import { installBlockFixtures } from '@/utils/test/fixtures/block';
|
||||
import { installContentFixtures } from '@/utils/test/fixtures/content';
|
||||
import { installSubscriberFixtures } from '@/utils/test/fixtures/subscriber';
|
||||
import { mockWebChannelData, textBlock } from '@/utils/test/mocks/block';
|
||||
import { conversationGetStarted } from '@/utils/test/mocks/conversation';
|
||||
import {
|
||||
closeInMongodConnection,
|
||||
rootMongooseTestModule,
|
||||
@ -212,157 +214,149 @@ describe('BotService', () => {
|
||||
|
||||
afterEach(jest.clearAllMocks);
|
||||
afterAll(closeInMongodConnection);
|
||||
|
||||
it('should start a conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
describe('startConversation', () => {
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
const event = new WebEventWrapper(handler, webEventText, {
|
||||
isSocket: false,
|
||||
ipAddress: '1.1.1.1',
|
||||
agent: 'Chromium',
|
||||
});
|
||||
it('should start a conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
|
||||
const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] });
|
||||
const webSubscriber = (await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-web-1',
|
||||
}))!;
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
});
|
||||
|
||||
event.setSender(webSubscriber);
|
||||
|
||||
let hasBotSpoken = false;
|
||||
const clearMock = jest
|
||||
.spyOn(botService, 'triggerBlock')
|
||||
.mockImplementation(
|
||||
(
|
||||
actualEvent: WebEventWrapper<typeof WEB_CHANNEL_NAME>,
|
||||
actualConversation: Conversation,
|
||||
actualBlock: BlockFull,
|
||||
isFallback: boolean,
|
||||
) => {
|
||||
expect(actualConversation).toEqualPayload({
|
||||
sender: webSubscriber.id,
|
||||
active: true,
|
||||
next: [],
|
||||
context: {
|
||||
user: {
|
||||
first_name: webSubscriber.first_name,
|
||||
last_name: webSubscriber.last_name,
|
||||
language: 'en',
|
||||
id: webSubscriber.id,
|
||||
},
|
||||
user_location: {
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
},
|
||||
skip: {},
|
||||
vars: {},
|
||||
nlp: null,
|
||||
payload: null,
|
||||
attempt: 0,
|
||||
channel: 'web-channel',
|
||||
text: webEventText.data.text,
|
||||
},
|
||||
});
|
||||
expect(actualEvent).toEqual(event);
|
||||
expect(actualBlock).toEqual(block);
|
||||
expect(isFallback).toEqual(false);
|
||||
hasBotSpoken = true;
|
||||
},
|
||||
const event = new WebEventWrapper(
|
||||
handler,
|
||||
webEventText,
|
||||
mockWebChannelData,
|
||||
);
|
||||
|
||||
await botService.startConversation(event, block);
|
||||
expect(hasBotSpoken).toEqual(true);
|
||||
expect(triggeredEvents).toEqual([
|
||||
['popular', 'hasNextBlocks'],
|
||||
['new_conversations', 'New conversations'],
|
||||
]);
|
||||
clearMock.mockClear();
|
||||
});
|
||||
const [block] = await blockService.findAndPopulate({ patterns: ['Hi'] });
|
||||
const webSubscriber = (await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-web-1',
|
||||
}))!;
|
||||
|
||||
it('should capture a conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
event.setSender(webSubscriber);
|
||||
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
});
|
||||
|
||||
const event = new WebEventWrapper(handler, webEventText, {
|
||||
isSocket: false,
|
||||
ipAddress: '1.1.1.1',
|
||||
agent: 'Chromium',
|
||||
});
|
||||
const webSubscriber = (await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-web-1',
|
||||
}))!;
|
||||
event.setSender(webSubscriber);
|
||||
|
||||
const clearMock = jest
|
||||
.spyOn(botService, 'handleOngoingConversationMessage')
|
||||
.mockImplementation(
|
||||
async (
|
||||
actualConversation: ConversationFull,
|
||||
event: WebEventWrapper<typeof WEB_CHANNEL_NAME>,
|
||||
) => {
|
||||
expect(actualConversation).toEqualPayload({
|
||||
next: [],
|
||||
sender: webSubscriber,
|
||||
active: true,
|
||||
context: {
|
||||
user: {
|
||||
first_name: webSubscriber.first_name,
|
||||
last_name: webSubscriber.last_name,
|
||||
language: 'en',
|
||||
id: webSubscriber.id,
|
||||
let hasBotSpoken = false;
|
||||
const clearMock = jest
|
||||
.spyOn(botService, 'triggerBlock')
|
||||
.mockImplementation(
|
||||
(
|
||||
actualEvent: WebEventWrapper<typeof WEB_CHANNEL_NAME>,
|
||||
actualConversation: Conversation,
|
||||
actualBlock: BlockFull,
|
||||
isFallback: boolean,
|
||||
) => {
|
||||
expect(actualConversation).toEqualPayload({
|
||||
sender: webSubscriber.id,
|
||||
active: true,
|
||||
next: [],
|
||||
context: {
|
||||
user: {
|
||||
first_name: webSubscriber.first_name,
|
||||
last_name: webSubscriber.last_name,
|
||||
language: 'en',
|
||||
id: webSubscriber.id,
|
||||
},
|
||||
user_location: {
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
},
|
||||
skip: {},
|
||||
vars: {},
|
||||
nlp: null,
|
||||
payload: null,
|
||||
attempt: 0,
|
||||
channel: 'web-channel',
|
||||
text: webEventText.data.text,
|
||||
},
|
||||
user_location: { lat: 0, lon: 0 },
|
||||
vars: {},
|
||||
skip: {},
|
||||
nlp: null,
|
||||
payload: null,
|
||||
attempt: 0,
|
||||
channel: 'web-channel',
|
||||
text: webEventText.data.text,
|
||||
},
|
||||
});
|
||||
expect(event).toEqual(event);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
const captured = await botService.processConversationMessage(event);
|
||||
expect(captured).toBe(true);
|
||||
expect(triggeredEvents).toEqual([
|
||||
['existing_conversations', 'Existing conversations'],
|
||||
]);
|
||||
clearMock.mockClear();
|
||||
});
|
||||
expect(actualEvent).toEqual(event);
|
||||
expect(actualBlock).toEqual(block);
|
||||
expect(isFallback).toEqual(false);
|
||||
hasBotSpoken = true;
|
||||
},
|
||||
);
|
||||
|
||||
await botService.startConversation(event, block);
|
||||
expect(hasBotSpoken).toEqual(true);
|
||||
expect(triggeredEvents).toEqual([
|
||||
['popular', 'hasNextBlocks'],
|
||||
['new_conversations', 'New conversations'],
|
||||
]);
|
||||
clearMock.mockClear();
|
||||
});
|
||||
});
|
||||
|
||||
it('has no active conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
describe('processConversationMessage', () => {
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
const event = new WebEventWrapper(handler, webEventText, {
|
||||
isSocket: false,
|
||||
ipAddress: '1.1.1.1',
|
||||
agent: 'Chromium',
|
||||
});
|
||||
const webSubscriber = (await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-web-2',
|
||||
}))!;
|
||||
event.setSender(webSubscriber);
|
||||
const captured = await botService.processConversationMessage(event);
|
||||
|
||||
expect(captured).toBe(false);
|
||||
expect(triggeredEvents).toEqual([]);
|
||||
it('has no active conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
});
|
||||
const event = new WebEventWrapper(
|
||||
handler,
|
||||
webEventText,
|
||||
mockWebChannelData,
|
||||
);
|
||||
const webSubscriber = (await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-web-2',
|
||||
}))!;
|
||||
event.setSender(webSubscriber);
|
||||
const captured = await botService.processConversationMessage(event);
|
||||
|
||||
expect(captured).toBe(false);
|
||||
expect(triggeredEvents).toEqual([]);
|
||||
});
|
||||
|
||||
it('should capture a conversation', async () => {
|
||||
const triggeredEvents: any[] = [];
|
||||
|
||||
eventEmitter.on('hook:stats:entry', (...args) => {
|
||||
triggeredEvents.push(args);
|
||||
});
|
||||
|
||||
const event = new WebEventWrapper(
|
||||
handler,
|
||||
webEventText,
|
||||
mockWebChannelData,
|
||||
);
|
||||
const webSubscriber = (await subscriberService.findOne({
|
||||
foreign_id: 'foreign-id-web-1',
|
||||
}))!;
|
||||
event.setSender(webSubscriber);
|
||||
|
||||
jest
|
||||
.spyOn(botService, 'handleOngoingConversationMessage')
|
||||
.mockImplementation(() => Promise.resolve(true));
|
||||
const captured = await botService.processConversationMessage(event);
|
||||
expect(captured).toBe(true);
|
||||
expect(triggeredEvents).toEqual([
|
||||
['existing_conversations', 'Existing conversations'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('proceedToNextBlock', () => {
|
||||
const mockEvent = new WebEventWrapper(
|
||||
handler,
|
||||
webEventText,
|
||||
mockWebChannelData,
|
||||
);
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should emit stats and call triggerBlock, returning true on success and reset attempt if not fallback', async () => {
|
||||
const convo = {
|
||||
const mockConvo = {
|
||||
...conversationGetStarted,
|
||||
id: 'convo1',
|
||||
context: { attempt: 2 },
|
||||
next: [],
|
||||
@ -370,23 +364,20 @@ describe('BotService', () => {
|
||||
active: true,
|
||||
} as unknown as ConversationFull;
|
||||
const next = { id: 'block1', name: 'Block 1' } as BlockFull;
|
||||
const event = {} as any;
|
||||
const fallback = false;
|
||||
|
||||
jest
|
||||
.spyOn(conversationService, 'storeContextData')
|
||||
.mockImplementation((convo, _next, _event, _captureVars) => {
|
||||
return Promise.resolve({
|
||||
...convo,
|
||||
} as Conversation);
|
||||
.mockImplementation(() => {
|
||||
return Promise.resolve(mockConvo as unknown as Conversation);
|
||||
});
|
||||
|
||||
jest.spyOn(botService, 'triggerBlock').mockResolvedValue(undefined);
|
||||
const emitSpy = jest.spyOn(eventEmitter, 'emit');
|
||||
const result = await botService.proceedToNextBlock(
|
||||
convo,
|
||||
mockConvo,
|
||||
next,
|
||||
event,
|
||||
mockEvent,
|
||||
fallback,
|
||||
);
|
||||
|
||||
@ -397,48 +388,48 @@ describe('BotService', () => {
|
||||
);
|
||||
|
||||
expect(botService.triggerBlock).toHaveBeenCalledWith(
|
||||
event,
|
||||
mockEvent,
|
||||
expect.objectContaining({ id: 'convo1' }),
|
||||
next,
|
||||
fallback,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
expect(convo.context.attempt).toBe(0);
|
||||
expect(mockConvo.context.attempt).toBe(0);
|
||||
});
|
||||
|
||||
it('should increment attempt if fallback is true', async () => {
|
||||
const convo = {
|
||||
const mockConvo = {
|
||||
...conversationGetStarted,
|
||||
id: 'convo2',
|
||||
context: { attempt: 1 },
|
||||
next: [],
|
||||
sender: 'user2',
|
||||
active: true,
|
||||
} as any;
|
||||
} as unknown as ConversationFull;
|
||||
const next = { id: 'block2', name: 'Block 2' } as any;
|
||||
const event = {} as any;
|
||||
const fallback = true;
|
||||
|
||||
const result = await botService.proceedToNextBlock(
|
||||
convo,
|
||||
mockConvo,
|
||||
next,
|
||||
event,
|
||||
mockEvent,
|
||||
fallback,
|
||||
);
|
||||
|
||||
expect(convo.context.attempt).toBe(2);
|
||||
expect(mockConvo.context.attempt).toBe(2);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle errors and emit conversation:end, returning false', async () => {
|
||||
const convo = {
|
||||
const mockConvo = {
|
||||
...conversationGetStarted,
|
||||
id: 'convo3',
|
||||
context: { attempt: 1 },
|
||||
next: [],
|
||||
sender: 'user3',
|
||||
active: true,
|
||||
} as any;
|
||||
} as unknown as ConversationFull;
|
||||
const next = { id: 'block3', name: 'Block 3' } as any;
|
||||
const event = {} as any;
|
||||
const fallback = false;
|
||||
|
||||
jest
|
||||
@ -447,14 +438,136 @@ describe('BotService', () => {
|
||||
|
||||
const emitSpy = jest.spyOn(eventEmitter, 'emit');
|
||||
const result = await botService.proceedToNextBlock(
|
||||
convo,
|
||||
mockConvo,
|
||||
next,
|
||||
event,
|
||||
mockEvent,
|
||||
fallback,
|
||||
);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', convo);
|
||||
expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleOngoingConversationMessage', () => {
|
||||
const mockConvo = {
|
||||
...conversationGetStarted,
|
||||
id: 'convo1',
|
||||
context: { ...conversationGetStarted.context, attempt: 0 },
|
||||
next: [{ id: 'block1' }],
|
||||
current: {
|
||||
...conversationGetStarted.current,
|
||||
id: 'block0',
|
||||
options: {
|
||||
...conversationGetStarted.current.options,
|
||||
fallback: {
|
||||
active: true,
|
||||
max_attempts: 2,
|
||||
message: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ConversationFull;
|
||||
|
||||
const mockEvent = new WebEventWrapper(
|
||||
handler,
|
||||
webEventText,
|
||||
mockWebChannelData,
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should proceed to the matched next block', async () => {
|
||||
const matchedBlock = {
|
||||
...textBlock,
|
||||
id: 'block1',
|
||||
name: 'Block 1',
|
||||
} as BlockFull;
|
||||
jest
|
||||
.spyOn(blockService, 'findAndPopulate')
|
||||
.mockResolvedValue([matchedBlock]);
|
||||
jest.spyOn(blockService, 'match').mockResolvedValue(matchedBlock);
|
||||
jest.spyOn(botService, 'proceedToNextBlock').mockResolvedValue(true);
|
||||
|
||||
const result = await botService.handleOngoingConversationMessage(
|
||||
mockConvo,
|
||||
mockEvent,
|
||||
);
|
||||
|
||||
expect(blockService.findAndPopulate).toHaveBeenCalled();
|
||||
expect(blockService.match).toHaveBeenCalled();
|
||||
expect(botService.proceedToNextBlock).toHaveBeenCalled();
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should proceed to fallback block if no match and fallback is allowed', async () => {
|
||||
jest.spyOn(blockService, 'findAndPopulate').mockResolvedValue([]);
|
||||
jest.spyOn(blockService, 'match').mockResolvedValue(undefined);
|
||||
const proceedSpy = jest
|
||||
.spyOn(botService, 'proceedToNextBlock')
|
||||
.mockResolvedValue(true);
|
||||
|
||||
const result = await botService.handleOngoingConversationMessage(
|
||||
mockConvo,
|
||||
mockEvent,
|
||||
);
|
||||
|
||||
expect(proceedSpy).toHaveBeenCalledWith(
|
||||
mockConvo,
|
||||
expect.objectContaining({ id: 'block0', nextBlocks: mockConvo.next }),
|
||||
mockEvent,
|
||||
true,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should end conversation and return false if no match and fallback not allowed', async () => {
|
||||
const mockConvoWithoutFallback = {
|
||||
...mockConvo,
|
||||
current: {
|
||||
...mockConvo.current,
|
||||
options: {
|
||||
...mockConvo.current.options,
|
||||
fallback: {
|
||||
active: false,
|
||||
max_attempts: 2,
|
||||
message: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as ConversationFull;
|
||||
jest.spyOn(blockService, 'findAndPopulate').mockResolvedValue([]);
|
||||
jest.spyOn(blockService, 'match').mockResolvedValue(undefined);
|
||||
const emitSpy = jest.spyOn(eventEmitter, 'emit');
|
||||
|
||||
const result = await botService.handleOngoingConversationMessage(
|
||||
mockConvoWithoutFallback,
|
||||
mockEvent,
|
||||
);
|
||||
|
||||
expect(emitSpy).toHaveBeenCalledWith(
|
||||
'hook:conversation:end',
|
||||
mockConvoWithoutFallback,
|
||||
);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should end conversation and throw if an error occurs', async () => {
|
||||
jest
|
||||
.spyOn(blockService, 'findAndPopulate')
|
||||
.mockRejectedValue(new Error('fail'));
|
||||
const emitSpy = jest.spyOn(eventEmitter, 'emit');
|
||||
|
||||
await expect(
|
||||
botService.handleOngoingConversationMessage(mockConvo, mockEvent),
|
||||
).rejects.toThrow('fail');
|
||||
expect(emitSpy).toHaveBeenCalledWith('hook:conversation:end', mockConvo);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -28,6 +28,7 @@ import {
|
||||
OutgoingMessageFormat,
|
||||
StdOutgoingMessageEnvelope,
|
||||
} from '../schemas/types/message';
|
||||
import { BlockOptions } from '../schemas/types/options';
|
||||
|
||||
import { BlockService } from './block.service';
|
||||
import { ConversationService } from './conversation.service';
|
||||
@ -288,6 +289,27 @@ export class BotService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a fallback should be attempted based on the event type, fallback options, and conversation context.
|
||||
*
|
||||
* @param event - The incoming event that triggered the conversation flow.
|
||||
* @param fallbackOptions - The options for fallback behavior defined in the block.
|
||||
* @param convo - The current conversation object containing context and state.
|
||||
*
|
||||
* @returns A boolean indicating whether a fallback should be attempted.
|
||||
*/
|
||||
private shouldAttemptLocalFallback(
|
||||
event: EventWrapper<any, any>,
|
||||
fallbackOptions: BlockOptions['fallback'],
|
||||
convo: ConversationFull,
|
||||
): boolean {
|
||||
return (
|
||||
event.getMessageType() === IncomingMessageType.message &&
|
||||
!!fallbackOptions?.active &&
|
||||
convo.context.attempt < (fallbackOptions?.max_attempts ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and responds to an incoming message within an ongoing conversation flow.
|
||||
* Determines the next block in the conversation, attempts to match the message with available blocks,
|
||||
@ -302,25 +324,25 @@ export class BotService {
|
||||
convo: ConversationFull,
|
||||
event: EventWrapper<any, any>,
|
||||
) {
|
||||
const nextIds = convo.next.map(({ id }) => id);
|
||||
// Reload blocks in order to populate his nextBlocks
|
||||
// nextBlocks & trigger/assign _labels
|
||||
try {
|
||||
const nextBlocks = await this.blockService.findAndPopulate({
|
||||
_id: { $in: nextIds },
|
||||
});
|
||||
let fallback = false;
|
||||
const fallbackOptions = convo.current?.options?.fallback
|
||||
const currentBlock = convo.current;
|
||||
const fallbackOptions: BlockOptions['fallback'] = convo.current?.options
|
||||
?.fallback
|
||||
? convo.current.options.fallback
|
||||
: {
|
||||
active: false,
|
||||
max_attempts: 0,
|
||||
message: [],
|
||||
};
|
||||
|
||||
// We will avoid having multiple matches when we are not at the start of a conversation
|
||||
// and only if local fallback is enabled
|
||||
const canHaveMultipleMatches = !fallbackOptions.active;
|
||||
// Find the next block that matches
|
||||
const nextBlocks = await this.blockService.findAndPopulate({
|
||||
_id: { $in: convo.next.map(({ id }) => id) },
|
||||
});
|
||||
const matchedBlock = await this.blockService.match(
|
||||
nextBlocks,
|
||||
event,
|
||||
@ -331,13 +353,10 @@ export class BotService {
|
||||
let fallbackBlock: BlockFull | undefined = undefined;
|
||||
if (
|
||||
!matchedBlock &&
|
||||
event.getMessageType() === IncomingMessageType.message &&
|
||||
fallbackOptions.active &&
|
||||
convo.context.attempt < fallbackOptions.max_attempts
|
||||
this.shouldAttemptLocalFallback(event, fallbackOptions, convo)
|
||||
) {
|
||||
// Trigger block fallback
|
||||
// NOTE : current is not populated, this may cause some anomaly
|
||||
const currentBlock = convo.current;
|
||||
fallbackBlock = {
|
||||
...currentBlock,
|
||||
nextBlocks: convo.next,
|
||||
|
Loading…
Reference in New Issue
Block a user