Files
hexabot/api/src/chat/services/block.service.spec.ts
2025-06-09 16:11:01 +01:00

1148 lines
34 KiB
TypeScript

/*
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { MongooseModule } from '@nestjs/mongoose';
import { AttachmentRepository } from '@/attachment/repositories/attachment.repository';
import { AttachmentModel } from '@/attachment/schemas/attachment.schema';
import { AttachmentService } from '@/attachment/services/attachment.service';
import {
subscriberWithLabels,
subscriberWithoutLabels,
} from '@/channel/lib/__test__/subscriber.mock';
import { ButtonType, PayloadType } from '@/chat/schemas/types/button';
import { ContentTypeRepository } from '@/cms/repositories/content-type.repository';
import { ContentRepository } from '@/cms/repositories/content.repository';
import { ContentTypeModel } from '@/cms/schemas/content-type.schema';
import { Content, ContentModel } from '@/cms/schemas/content.schema';
import { ContentTypeService } from '@/cms/services/content-type.service';
import { ContentService } from '@/cms/services/content.service';
import WebChannelHandler from '@/extensions/channels/web/index.channel';
import { WEB_CHANNEL_NAME } from '@/extensions/channels/web/settings';
import { Web } from '@/extensions/channels/web/types';
import WebEventWrapper from '@/extensions/channels/web/wrapper';
import { HelperService } from '@/helper/helper.service';
import { LanguageRepository } from '@/i18n/repositories/language.repository';
import { LanguageModel } from '@/i18n/schemas/language.schema';
import { I18nService } from '@/i18n/services/i18n.service';
import { LanguageService } from '@/i18n/services/language.service';
import { NlpEntityRepository } from '@/nlp/repositories/nlp-entity.repository';
import { NlpSampleEntityRepository } from '@/nlp/repositories/nlp-sample-entity.repository';
import { NlpSampleRepository } from '@/nlp/repositories/nlp-sample.repository';
import { NlpValueRepository } from '@/nlp/repositories/nlp-value.repository';
import { NlpEntityModel } from '@/nlp/schemas/nlp-entity.schema';
import { NlpSampleEntityModel } from '@/nlp/schemas/nlp-sample-entity.schema';
import { NlpSampleModel } from '@/nlp/schemas/nlp-sample.schema';
import { NlpValueModel } from '@/nlp/schemas/nlp-value.schema';
import { NlpEntityService } from '@/nlp/services/nlp-entity.service';
import { NlpSampleEntityService } from '@/nlp/services/nlp-sample-entity.service';
import { NlpSampleService } from '@/nlp/services/nlp-sample.service';
import { NlpValueService } from '@/nlp/services/nlp-value.service';
import { NlpService } from '@/nlp/services/nlp.service';
import { PluginService } from '@/plugins/plugins.service';
import { SettingService } from '@/setting/services/setting.service';
import { FALLBACK_DEFAULT_NLU_PENALTY_FACTOR } from '@/utils/constants/nlp';
import {
blockFixtures,
installBlockFixtures,
} from '@/utils/test/fixtures/block';
import { installContentFixtures } from '@/utils/test/fixtures/content';
import { installNlpValueFixtures } from '@/utils/test/fixtures/nlpvalue';
import {
blockEmpty,
blockGetStarted,
blockProductListMock,
blocks,
mockNlpAffirmationPatterns,
mockNlpFirstNamePatterns,
mockNlpGreetingAnyNamePatterns,
mockNlpGreetingNamePatterns,
mockNlpGreetingPatterns,
mockNlpGreetingWrongNamePatterns,
mockWebChannelData,
} from '@/utils/test/mocks/block';
import {
contextBlankInstance,
subscriberContextBlankInstance,
} from '@/utils/test/mocks/conversation';
import {
mockNlpFirstNameEntities,
mockNlpGreetingFullNameEntities,
mockNlpGreetingNameEntities,
} from '@/utils/test/mocks/nlp';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { buildTestingMocks } from '@/utils/test/utils';
import { BlockRepository } from '../repositories/block.repository';
import { Block, BlockFull, BlockModel } from '../schemas/block.schema';
import { Category, CategoryModel } from '../schemas/category.schema';
import { LabelModel } from '../schemas/label.schema';
import { Subscriber } from '../schemas/subscriber.schema';
import { FileType } from '../schemas/types/attachment';
import { Context } from '../schemas/types/context';
import {
OutgoingMessageFormat,
StdOutgoingListMessage,
} from '../schemas/types/message';
import { QuickReplyType } from '../schemas/types/quick-reply';
import { CategoryRepository } from './../repositories/category.repository';
import { BlockService } from './block.service';
import { CategoryService } from './category.service';
function makeMockBlock(overrides: Partial<Block>): Block {
return {
id: 'default',
message: [],
trigger_labels: [],
assign_labels: [],
nextBlocks: [],
attachedBlock: null,
category: null,
name: '',
patterns: [],
outcomes: [],
trigger_channels: [],
options: {},
starts_conversation: false,
capture_vars: [],
position: { x: 0, y: 0 },
builtin: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe('BlockService', () => {
let blockRepository: BlockRepository;
let categoryRepository: CategoryRepository;
let category: Category;
let block: Block;
let blockService: BlockService;
let hasPreviousBlocks: Block;
let contentService: ContentService;
let contentTypeService: ContentTypeService;
beforeAll(async () => {
const { getMocks } = await buildTestingMocks({
imports: [
rootMongooseTestModule(async () => {
await installContentFixtures();
await installBlockFixtures();
await installNlpValueFixtures();
}),
MongooseModule.forFeature([
BlockModel,
CategoryModel,
ContentTypeModel,
ContentModel,
AttachmentModel,
LabelModel,
LanguageModel,
NlpEntityModel,
NlpSampleEntityModel,
NlpValueModel,
NlpSampleModel,
]),
],
providers: [
BlockRepository,
CategoryRepository,
ContentTypeRepository,
ContentRepository,
AttachmentRepository,
LanguageRepository,
BlockService,
CategoryService,
ContentTypeService,
ContentService,
AttachmentService,
LanguageService,
NlpEntityRepository,
NlpValueRepository,
NlpSampleRepository,
NlpSampleEntityRepository,
NlpEntityService,
NlpValueService,
NlpSampleService,
NlpSampleEntityService,
NlpService,
HelperService,
{
provide: PluginService,
useValue: {},
},
{
provide: I18nService,
useValue: {
t: jest.fn().mockImplementation((t) => {
return t === 'Welcome' ? 'Bienvenue' : t;
}),
},
},
{
provide: SettingService,
useValue: {
getConfig: jest.fn(() => ({
chatbot: { lang: { default: 'fr' } },
})),
getSettings: jest.fn(() => ({
contact: { company_name: 'Your company name' },
chatbot_settings: { default_nlu_penalty_factor: 0.95 },
})),
},
},
{
provide: CACHE_MANAGER,
useValue: {
del: jest.fn(),
get: jest.fn(),
set: jest.fn(),
},
},
],
});
[
blockService,
contentService,
contentTypeService,
categoryRepository,
blockRepository,
] = await getMocks([
BlockService,
ContentService,
ContentTypeService,
CategoryRepository,
BlockRepository,
]);
category = (await categoryRepository.findOne({ label: 'default' }))!;
hasPreviousBlocks = (await blockRepository.findOne({
name: 'hasPreviousBlocks',
}))!;
block = (await blockRepository.findOne({ name: 'hasNextBlocks' }))!;
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('findOneAndPopulate', () => {
it('should find one block by id, and populate its trigger_labels, assign_labels,attachedBlock,category,nextBlocks', async () => {
jest.spyOn(blockRepository, 'findOneAndPopulate');
const result = await blockService.findOneAndPopulate(block.id);
expect(blockRepository.findOneAndPopulate).toHaveBeenCalledWith(
block.id,
undefined,
);
expect(result).toEqualPayload({
...blockFixtures.find(({ name }) => name === 'hasNextBlocks'),
category,
nextBlocks: [hasPreviousBlocks],
previousBlocks: [],
attachedToBlock: null,
});
});
});
describe('findAndPopulate', () => {
it('should find blocks and populate them', async () => {
jest.spyOn(blockRepository, 'findAndPopulate');
const result = await blockService.findAndPopulate({});
const blocksWithCategory = blockFixtures.map((blockFixture) => ({
...blockFixture,
category,
previousBlocks:
blockFixture.name === 'hasPreviousBlocks' ? [block] : [],
nextBlocks:
blockFixture.name === 'hasNextBlocks' ? [hasPreviousBlocks] : [],
attachedToBlock: null,
}));
expect(blockRepository.findAndPopulate).toHaveBeenCalledWith(
{},
undefined,
undefined,
);
expect(result).toEqualPayload(blocksWithCategory);
});
});
describe('match', () => {
const handlerMock = {
getName: jest.fn(() => WEB_CHANNEL_NAME),
} as any as WebChannelHandler;
const webEventGreeting = new WebEventWrapper(
handlerMock,
{
type: Web.IncomingMessageType.text,
data: {
text: 'Hello',
},
},
mockWebChannelData,
);
const webEventGetStarted = new WebEventWrapper(
handlerMock,
{
type: Web.IncomingMessageType.postback,
data: {
text: 'Get Started',
payload: 'GET_STARTED',
},
},
mockWebChannelData,
);
const webEventAmbiguous = new WebEventWrapper(
handlerMock,
{
type: Web.IncomingMessageType.text,
data: {
text: "It's not a yes or no answer!",
},
},
mockWebChannelData,
);
it('should return undefined when no blocks are provided', async () => {
const result = await blockService.match([], webEventGreeting);
expect(result).toBe(undefined);
});
it('should return undefined for empty blocks', async () => {
const result = await blockService.match([blockEmpty], webEventGreeting);
expect(result).toEqual(undefined);
});
it('should return undefined for no matching labels', async () => {
webEventGreeting.setSender(subscriberWithoutLabels);
const result = await blockService.match(blocks, webEventGreeting);
expect(result).toEqual(undefined);
});
it('should match block text and labels', async () => {
webEventGreeting.setSender(subscriberWithLabels);
const result = await blockService.match(blocks, webEventGreeting);
expect(result).toEqual(blockGetStarted);
});
it('should return undefined when multiple matches are not allowed', async () => {
const result = await blockService.match(
[
{
...blockEmpty,
patterns: ['/yes/'],
},
{
...blockEmpty,
patterns: ['/no/'],
},
],
webEventAmbiguous,
false,
);
expect(result).toEqual(undefined);
});
it('should match block with payload', async () => {
webEventGetStarted.setSender(subscriberWithLabels);
const result = await blockService.match(blocks, webEventGetStarted);
expect(result).toEqual(blockGetStarted);
});
it('should match block with nlp', async () => {
webEventGreeting.setSender(subscriberWithLabels);
webEventGreeting.setNLP(mockNlpGreetingFullNameEntities);
const result = await blockService.match(blocks, webEventGreeting);
expect(result).toEqual(blockGetStarted);
});
});
describe('matchNLP', () => {
it('should return an empty array for a block with no NLP patterns', () => {
const result = blockService.getMatchingNluPatterns(
mockNlpGreetingFullNameEntities,
blockEmpty,
);
expect(result).toEqual([]);
});
it('should return an empty array when no NLP entities are provided', () => {
const result = blockService.getMatchingNluPatterns(
{ entities: [] },
blockGetStarted,
);
expect(result).toEqual([]);
});
it('should return match nlp patterns', () => {
const result = blockService.getMatchingNluPatterns(
mockNlpGreetingFullNameEntities,
{
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns],
},
);
expect(result).toEqual([
[
{
entity: 'intent',
match: 'value',
value: 'greeting',
},
{
entity: 'firstname',
match: 'value',
value: 'jhon',
},
],
]);
});
it('should return match nlp patterns with synonyms match (canonical value)', () => {
const result = blockService.getMatchingNluPatterns(
mockNlpFirstNameEntities,
{
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpFirstNamePatterns],
},
);
expect(result).toEqual([
[
{
entity: 'firstname',
match: 'value',
value: 'jhon',
},
],
]);
});
it('should return empty array when it does not match nlp patterns', () => {
const result = blockService.getMatchingNluPatterns(
mockNlpGreetingFullNameEntities,
{
...blockGetStarted,
patterns: [
[{ entity: 'lastname', match: 'value', value: 'Belakhel' }],
],
},
);
expect(result).toEqual([]);
});
it('should return empty array when unknown nlp patterns', () => {
const result = blockService.getMatchingNluPatterns(
mockNlpGreetingFullNameEntities,
{
...blockGetStarted,
patterns: [[{ entity: 'product', match: 'value', value: 'pizza' }]],
},
);
expect(result).toEqual([]);
});
});
describe('matchBestNLP', () => {
it('should return the block with the highest NLP score', async () => {
const mockExpectedBlock: BlockFull = {
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns],
};
const blocks: BlockFull[] = [
// no match
blockGetStarted,
// match
mockExpectedBlock,
// match
{
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpGreetingPatterns],
},
// no match
{
...blockGetStarted,
patterns: [
...blockGetStarted.patterns,
mockNlpGreetingWrongNamePatterns,
],
},
// no match
{
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpAffirmationPatterns],
},
// no match
blockGetStarted,
];
// Spy on calculateBlockScore to check if it's called
const calculateBlockScoreSpy = jest.spyOn(
blockService,
'calculateNluPatternMatchScore',
);
const bestBlock = blockService.matchBestNLP(
blocks,
mockNlpGreetingNameEntities,
FALLBACK_DEFAULT_NLU_PENALTY_FACTOR,
);
// Ensure calculateBlockScore was called at least once for each block
expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(2); // Called for each block
// Assert that the block with the highest NLP score is selected
expect(bestBlock).toEqual(mockExpectedBlock);
});
it('should return the block with the highest NLP score applying penalties', async () => {
const mockExpectedBlock: BlockFull = {
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpGreetingNamePatterns],
};
const blocks: BlockFull[] = [
// no match
blockGetStarted,
// match
mockExpectedBlock,
// match
{
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpGreetingPatterns],
},
// match
{
...blockGetStarted,
patterns: [
...blockGetStarted.patterns,
mockNlpGreetingAnyNamePatterns,
],
},
];
const nlp = mockNlpGreetingNameEntities;
// Spy on calculateBlockScore to check if it's called
const calculateBlockScoreSpy = jest.spyOn(
blockService,
'calculateNluPatternMatchScore',
);
const bestBlock = blockService.matchBestNLP(
blocks,
nlp,
FALLBACK_DEFAULT_NLU_PENALTY_FACTOR,
);
// Ensure calculateBlockScore was called at least once for each block
expect(calculateBlockScoreSpy).toHaveBeenCalledTimes(3); // Called for each block
// Assert that the block with the highest NLP score is selected
expect(bestBlock).toEqual(mockExpectedBlock);
});
it('should return undefined if no blocks match or the list is empty', async () => {
const blocks: BlockFull[] = [
{
...blockGetStarted,
patterns: [...blockGetStarted.patterns, mockNlpAffirmationPatterns],
},
blockGetStarted,
];
const bestBlock = blockService.matchBestNLP(
blocks,
mockNlpGreetingNameEntities,
FALLBACK_DEFAULT_NLU_PENALTY_FACTOR,
);
// Assert that undefined is returned when no blocks are available
expect(bestBlock).toBeUndefined();
});
});
describe('calculateNluPatternMatchScore', () => {
it('should calculate the correct NLP score for a block', async () => {
const matchingScore = blockService.calculateNluPatternMatchScore(
mockNlpGreetingNamePatterns,
mockNlpGreetingNameEntities,
FALLBACK_DEFAULT_NLU_PENALTY_FACTOR,
);
expect(matchingScore).toBeGreaterThan(0);
});
it('should calculate the correct NLP score for a block and apply penalties ', async () => {
const scoreWithoutPenalty = blockService.calculateNluPatternMatchScore(
mockNlpGreetingNamePatterns,
mockNlpGreetingNameEntities,
FALLBACK_DEFAULT_NLU_PENALTY_FACTOR,
);
const scoreWithPenalty = blockService.calculateNluPatternMatchScore(
mockNlpGreetingAnyNamePatterns,
mockNlpGreetingNameEntities,
FALLBACK_DEFAULT_NLU_PENALTY_FACTOR,
);
expect(scoreWithoutPenalty).toBeGreaterThan(scoreWithPenalty);
});
it('should handle invalid case for penalty factor values', async () => {
// Test with invalid penalty (should use fallback)
const scoreWithInvalidPenalty =
blockService.calculateNluPatternMatchScore(
mockNlpGreetingAnyNamePatterns,
mockNlpGreetingNameEntities,
-1,
);
expect(scoreWithInvalidPenalty).toBeGreaterThan(0); // Should use fallback value
});
});
describe('matchPayload', () => {
it('should return undefined for empty payload', () => {
const result = blockService.matchPayload('', blockGetStarted);
expect(result).toEqual(undefined);
});
it('should return undefined for empty block', () => {
const result = blockService.matchPayload('test', blockEmpty);
expect(result).toEqual(undefined);
});
it('should match payload and return object for label string', () => {
const location = {
label: 'Tounes',
value: 'Tounes',
type: 'location',
};
const result = blockService.matchPayload('Tounes', blockGetStarted);
expect(result).toEqual(location);
});
it('should match payload and return object for value string', () => {
const result = blockService.matchPayload('GET_STARTED', blockGetStarted);
expect(result).toEqual({
label: 'Get Started',
value: 'GET_STARTED',
});
});
it("should match payload when it's an attachment location", () => {
const result = blockService.matchPayload(
{
type: PayloadType.location,
coordinates: {
lat: 15,
lon: 23,
},
},
blockGetStarted,
);
expect(result).toEqual(blockGetStarted.patterns?.[3]);
});
it("should match payload when it's an attachment file", () => {
const result = blockService.matchPayload(
{
type: PayloadType.attachments,
attachment: {
type: FileType.file,
payload: {
id: '9'.repeat(24),
url: 'http://link.to/the/file',
},
},
},
blockGetStarted,
);
expect(result).toEqual(blockGetStarted.patterns?.[4]);
});
});
describe('matchText', () => {
it('should return false for matching an empty text', () => {
const result = blockService.matchText('', blockGetStarted);
expect(result).toEqual(false);
});
it('should match text message', () => {
const result = blockService.matchText('Hello', blockGetStarted);
expect(result).toEqual(['Hello']);
});
it('should match regex text message', () => {
const result = blockService.matchText(
'weeeelcome to our house',
blockGetStarted,
);
expect(result).toEqualPayload(
['weeeelcome'],
['index', 'index', 'input', 'groups'],
);
});
it("should return false when there's no match", () => {
const result = blockService.matchText(
'Goodbye Mr black',
blockGetStarted,
);
expect(result).toEqual(false);
});
it('should return false when matching message against a block with no patterns', () => {
const result = blockService.matchText('Hello', blockEmpty);
expect(result).toEqual(false);
});
});
describe('processMessage', () => {
// generic inputs we re-use
const ctx: Context = {
vars: {
phone: '+1123456789',
},
user_location: {
address: undefined,
lat: 0,
lon: 0,
},
user: { id: 'user-id', first_name: 'Jhon', last_name: 'Doe' } as any,
skip: {},
attempt: 0,
}; // Context
const subCtx: Subscriber['context'] = {
vars: {
color: 'green',
},
}; // SubscriberContext
const conversationId = 'conv-id';
it('should return a text envelope when the block is a text block', async () => {
const block = makeMockBlock({
message: [
'Hello {{context.user.first_name}}, your phone is {{context.vars.phone}} and your favorite color is {{context.vars.color}}',
],
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
false,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.text,
message: {
text: 'Hello Jhon, your phone is +1123456789 and your favorite color is green',
},
});
});
it('should return a text envelope when the block is a text block (local fallback)', async () => {
const block = makeMockBlock({
message: ['Hello world!'],
options: {
fallback: {
active: true,
max_attempts: 1,
message: ['Local fallback message ...'],
},
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
true,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.text,
message: {
text: 'Local fallback message ...',
},
});
});
it('should return a quick replies envelope when the block message has quickReplies', async () => {
const block = makeMockBlock({
message: {
text: '{{context.user.first_name}}, is this your phone number? {{context.vars.phone}}',
quickReplies: [
{ content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' },
{ content_type: QuickReplyType.text, title: 'No', payload: 'NO' },
],
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
false,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.quickReplies,
message: {
text: 'Jhon, is this your phone number? +1123456789',
quickReplies: [
{ content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' },
{ content_type: QuickReplyType.text, title: 'No', payload: 'NO' },
],
},
});
});
it('should return a quick replies envelope when the block message has quickReplies (local fallback)', async () => {
const block = makeMockBlock({
message: {
text: '{{context.user.first_name}}, are you there?',
quickReplies: [
{ content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' },
{ content_type: QuickReplyType.text, title: 'No', payload: 'NO' },
],
},
options: {
fallback: {
active: true,
max_attempts: 1,
message: ['Local fallback message ...'],
},
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
true,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.quickReplies,
message: {
text: 'Local fallback message ...',
quickReplies: [
{ content_type: QuickReplyType.text, title: 'Yes', payload: 'YES' },
{ content_type: QuickReplyType.text, title: 'No', payload: 'NO' },
],
},
});
});
it('should return a buttons envelope when the block message has buttons', async () => {
const block = makeMockBlock({
message: {
text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?',
buttons: [
{ type: ButtonType.postback, title: 'Red', payload: 'RED' },
{ type: ButtonType.postback, title: 'Green', payload: 'GREEN' },
],
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
false,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.buttons,
message: {
text: 'Jhon Doe, what color do you like? green?',
buttons: [
{
type: ButtonType.postback,
title: 'Red',
payload: 'RED',
},
{
type: ButtonType.postback,
title: 'Green',
payload: 'GREEN',
},
],
},
});
});
it('should return a buttons envelope when the block message has buttons (local fallback)', async () => {
const block = makeMockBlock({
message: {
text: '{{context.user.first_name}} {{context.user.last_name}}, what color do you like? {{context.vars.color}}?',
buttons: [
{ type: ButtonType.postback, title: 'Red', payload: 'RED' },
{ type: ButtonType.postback, title: 'Green', payload: 'GREEN' },
],
},
options: {
fallback: {
active: true,
max_attempts: 1,
message: ['Local fallback message ...'],
},
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
true,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.buttons,
message: {
text: 'Local fallback message ...',
buttons: [
{
type: ButtonType.postback,
title: 'Red',
payload: 'RED',
},
{
type: ButtonType.postback,
title: 'Green',
payload: 'GREEN',
},
],
},
});
});
it('should return an attachment envelope when payload has an id', async () => {
const block = makeMockBlock({
message: {
attachment: {
type: FileType.image,
payload: { id: 'ABC123' },
},
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
false,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.attachment,
message: {
attachment: {
type: 'image',
payload: { id: 'ABC123' },
},
},
});
});
it('should return an attachment envelope when payload has an id (local fallback)', async () => {
const block = makeMockBlock({
message: {
attachment: {
type: FileType.image,
payload: { id: 'ABC123' },
},
quickReplies: [],
},
options: {
fallback: {
active: true,
max_attempts: 1,
message: ['Local fallback ...'],
},
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
true,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.text,
message: {
text: 'Local fallback ...',
},
});
});
it('should keep quickReplies when present in an attachment block', async () => {
const block = makeMockBlock({
message: {
attachment: {
type: FileType.video,
payload: { id: 'VID42' },
},
quickReplies: [
{
content_type: QuickReplyType.text,
title: 'Replay',
payload: 'REPLAY',
},
{
content_type: QuickReplyType.text,
title: 'Next',
payload: 'NEXT',
},
],
},
});
const env = await blockService.processMessage(
block,
ctx,
subCtx,
false,
conversationId,
);
expect(env).toEqual({
format: OutgoingMessageFormat.attachment,
message: {
attachment: {
type: FileType.video,
payload: {
id: 'VID42',
},
},
quickReplies: [
{
content_type: QuickReplyType.text,
title: 'Replay',
payload: 'REPLAY',
},
{
content_type: QuickReplyType.text,
title: 'Next',
payload: 'NEXT',
},
],
},
});
});
it('should throw when attachment payload misses an id (remote URLs deprecated)', async () => {
const spyCheckDeprecated = jest
.spyOn(blockService as any, 'checkDeprecatedAttachmentUrl')
.mockImplementation(() => {});
const block = makeMockBlock({
message: {
attachment: {
type: FileType.image,
payload: { url: 'https://example.com/old-way.png' }, // no "id"
},
},
});
await expect(
blockService.processMessage(block, ctx, subCtx, false, conversationId),
).rejects.toThrow(
'Remote attachments in blocks are no longer supported!',
);
expect(spyCheckDeprecated).toHaveBeenCalledTimes(1);
spyCheckDeprecated.mockRestore();
});
it('should process list message (with limit = 2 and skip = 0)', async () => {
const contentType = (await contentTypeService.findOne({
name: 'Product',
}))!;
blockProductListMock.options.content!.entity = contentType.id;
const result = await blockService.processMessage(
blockProductListMock,
{
...contextBlankInstance,
skip: { [blockProductListMock.id]: 0 },
},
subscriberContextBlankInstance,
false,
'conv_id',
);
const elements = await contentService.findPage(
{ status: true, entity: contentType.id },
{ skip: 0, limit: 2, sort: ['createdAt', 'desc'] },
);
const flattenedElements = elements.map(Content.toElement);
expect(result.format).toEqualPayload(
blockProductListMock.options.content!.display,
);
expect(
(result.message as StdOutgoingListMessage).elements,
).toEqualPayload(flattenedElements);
expect((result.message as StdOutgoingListMessage).options).toEqualPayload(
blockProductListMock.options.content!,
);
expect(
(result.message as StdOutgoingListMessage).pagination,
).toEqualPayload({ total: 4, skip: 0, limit: 2 });
});
it('should process list message (with limit = 2 and skip = 2)', async () => {
const contentType = (await contentTypeService.findOne({
name: 'Product',
}))!;
blockProductListMock.options.content!.entity = contentType.id;
const result = await blockService.processMessage(
blockProductListMock,
{
...contextBlankInstance,
skip: { [blockProductListMock.id]: 2 },
},
subscriberContextBlankInstance,
false,
'conv_id',
);
const elements = await contentService.findPage(
{ status: true, entity: contentType.id },
{ skip: 2, limit: 2, sort: ['createdAt', 'desc'] },
);
const flattenedElements = elements.map(Content.toElement);
expect(result.format).toEqual(
blockProductListMock.options.content?.display,
);
expect((result.message as StdOutgoingListMessage).elements).toEqual(
flattenedElements,
);
expect((result.message as StdOutgoingListMessage).options).toEqual(
blockProductListMock.options.content,
);
expect((result.message as StdOutgoingListMessage).pagination).toEqual({
total: 4,
skip: 2,
limit: 2,
});
});
});
});