fix: permanent ctx vars update + unit tests

This commit is contained in:
Mohamed Marrouchi 2025-05-16 19:17:54 +01:00
parent 8033eb3099
commit c9f521a837
7 changed files with 377 additions and 17 deletions

View File

@ -128,6 +128,7 @@ describe('SubscriberController', () => {
),
labels: labelIDs,
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id)?.id,
context: undefined,
});
});
@ -148,6 +149,7 @@ describe('SubscriberController', () => {
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
context: undefined,
});
});
});

View File

@ -119,6 +119,7 @@ describe('SubscriberRepository', () => {
subscriber.labels.includes(label.id),
),
assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id),
context: undefined,
};
expect(subscriberModel.findById).toHaveBeenCalledWith(

View File

@ -0,0 +1,354 @@
/*
* 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 { 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 EventWrapper from '@/channel/lib/EventWrapper';
import { installContextVarFixtures } from '@/utils/test/fixtures/contextvar';
import { installConversationTypeFixtures } from '@/utils/test/fixtures/conversation';
import {
closeInMongodConnection,
rootMongooseTestModule,
} from '@/utils/test/test';
import { buildTestingMocks } from '@/utils/test/utils';
import { VIEW_MORE_PAYLOAD } from '../helpers/constants';
import { ContextVarRepository } from '../repositories/context-var.repository';
import { ConversationRepository } from '../repositories/conversation.repository';
import { SubscriberRepository } from '../repositories/subscriber.repository';
import { Block } from '../schemas/block.schema';
import { ContextVarModel } from '../schemas/context-var.schema';
import { ConversationModel } from '../schemas/conversation.schema';
import { SubscriberModel } from '../schemas/subscriber.schema';
import { OutgoingMessageFormat } from '../schemas/types/message';
import { ContextVarService } from './context-var.service';
import { ConversationService } from './conversation.service';
import { SubscriberService } from './subscriber.service';
describe('ConversationService', () => {
let conversationService: ConversationService;
let subscriberService: SubscriberService;
// let labelService: LabelService;
// let subscriberRepository: SubscriberRepository;
// let allSubscribers: Subscriber[];
// let allLabels: Label[];
// let labelsWithUsers: LabelFull[];
beforeAll(async () => {
const { getMocks } = await buildTestingMocks({
imports: [
rootMongooseTestModule(async () => {
await installContextVarFixtures();
await installConversationTypeFixtures();
}),
MongooseModule.forFeature([
// LabelModel,
SubscriberModel,
ConversationModel,
AttachmentModel,
ContextVarModel,
]),
],
providers: [
// LabelService,
// LabelRepository,
AttachmentService,
SubscriberService,
ConversationService,
ContextVarService,
AttachmentRepository,
SubscriberRepository,
ConversationRepository,
ContextVarRepository,
],
});
[conversationService, subscriberService] = await getMocks([
ConversationService,
SubscriberService,
]);
// allSubscribers = await subscriberRepository.findAll();
// allLabels = await labelRepository.findAll();
// labelsWithUsers = allLabels.map((label) => ({
// ...label,
// users: allSubscribers,
// }));
});
afterEach(jest.clearAllMocks);
afterAll(closeInMongodConnection);
describe('ConversationService.storeContextData', () => {
it('should enrich the conversation context and persist conversation + subscriber (permanent)', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [{ entity: -1, context_var: 'phone' }],
} as Block;
const mockPhone = '+1 514 678 9873';
const event = {
getMessageType: jest.fn().mockReturnValue('message'),
getText: jest.fn().mockReturnValue(mockPhone),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn().mockReturnValue(undefined),
getMessage: jest.fn().mockReturnValue({
text: mockPhone,
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result = await conversationService.storeContextData(
conversation!,
next,
event,
true,
);
// ---- Assertions ------------------------------------------------------
expect(result.context.channel).toBe('messenger-channel');
expect(result.context.text).toBe(mockPhone);
expect(result.context.vars.phone).toBe(mockPhone);
expect(result.context.user).toEqual({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
});
const updatedSubscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
expect(updatedSubscriber.context.vars?.phone).toBe(mockPhone);
// expect(event.setSender).toHaveBeenCalledWith(updatedSubscriber);
});
it('should capture an NLP entity value into context vars (non-permanent)', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [{ entity: 'country_code', context_var: 'country' }],
} as Block;
const mockMessage = 'Are you from the US?';
const event = {
getMessageType: jest.fn().mockReturnValue('message'),
getText: jest.fn().mockReturnValue(mockMessage),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn().mockReturnValue({
entities: [
{
entity: 'country_code',
value: 'US',
},
],
}),
getMessage: jest.fn().mockReturnValue({
text: mockMessage,
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result = await conversationService.storeContextData(
conversation,
next,
event,
true,
);
expect(result.context.vars.country).toBe('US');
const updatedSubscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
expect(updatedSubscriber.context.vars?.country).toBe(undefined);
});
it('should capture user coordinates when message type is "location"', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [{ entity: 'country_code', context_var: 'country' }],
} as Block;
const event = {
getMessageType: jest.fn().mockReturnValue('location'),
getText: jest.fn().mockReturnValue(''),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn(),
getMessage: jest.fn().mockReturnValue({
coordinates: { lat: 36.8065, lon: 10.1815 },
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result = await conversationService.storeContextData(
conversation,
next,
event,
);
expect(result.context.user_location).toEqual({
lat: 36.8065,
lon: 10.1815,
});
});
it('should increment skip when VIEW_MORE payload is received for list/carousel blocks', async () => {
const subscriber = (await subscriberService.findOne({
foreign_id: 'foreign-id-messenger',
}))!;
const conversation = (await conversationService.findOne({
sender: subscriber.id,
}))!;
const next = {
id: 'block-1',
capture_vars: [],
options: {
content: {
display: OutgoingMessageFormat.list,
limit: 10,
},
},
} as unknown as Block;
const event = {
getMessageType: jest.fn().mockReturnValue('message'),
getText: jest.fn().mockReturnValue('I would like to see the products'),
getPayload: jest.fn().mockReturnValue(undefined),
getNLP: jest.fn(),
getMessage: jest.fn().mockReturnValue({
text: 'I would like to see the products',
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
const result1 = await conversationService.storeContextData(
conversation,
next,
event,
);
expect(result1.context.skip['block-1']).toBe(0);
const event2 = {
getMessageType: jest.fn().mockReturnValue('postback'),
getText: jest.fn().mockReturnValue('View more'),
getPayload: jest.fn().mockReturnValue(VIEW_MORE_PAYLOAD),
getNLP: jest.fn(),
getMessage: jest.fn().mockReturnValue({
coordinates: { lat: 36.8065, lon: 10.1815 },
}),
getHandler: jest.fn().mockReturnValue({
getName: jest.fn().mockReturnValue('messenger-channel'),
}),
getSender: jest.fn().mockReturnValue({
id: subscriber.id,
first_name: subscriber.first_name,
last_name: subscriber.last_name,
language: subscriber.language,
context: {
vars: {
email: 'john.doe@mail.com',
},
},
}),
setSender: jest.fn(),
} as unknown as EventWrapper<any, any>;
// Second call to ensure the offset keeps growing
const result2 = await conversationService.storeContextData(
conversation,
next,
event2,
);
expect(result2.context.skip['block-1']).toBe(10);
});
});
});

View File

@ -196,8 +196,10 @@ export const installBlockFixtures = async () => {
category: defaultCategory.id,
})),
);
return await Block.updateOne(
await Block.updateOne(
{ name: 'hasNextBlocks' },
{ $set: { nextBlocks: blocks[1].id } },
);
return blocks;
};

View File

@ -21,6 +21,16 @@ export const contentVarDefaultValues: TContentVarFixtures['defaultValues'] = {
};
const contextVars: TContentVarFixtures['values'][] = [
{
label: 'Phone',
name: 'phone',
permanent: true,
},
{
label: 'Country',
name: 'country',
permanent: false,
},
{
label: 'test context var 1',
name: 'test1',

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* 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.
@ -10,6 +10,7 @@ import mongoose from 'mongoose';
import { ConversationCreateDto } from '@/chat/dto/conversation.dto';
import { ConversationModel } from '@/chat/schemas/conversation.schema';
import { Subscriber } from '@/chat/schemas/subscriber.schema';
import { getFixturesWithDefaultValues } from '../defaultValues';
import { TFixturesDefaultValues } from '../types';
@ -45,23 +46,10 @@ const conversations: ConversationCreateDto[] = [
},
user: {
id: '1',
createdAt: new Date(),
updatedAt: new Date(),
first_name: 'Jhon',
last_name: 'Doe',
language: 'fr',
locale: 'en_EN',
timezone: 0,
gender: 'male',
country: 'FR',
foreign_id: '',
labels: [],
assignedTo: null,
channel: { name: 'messenger-channel' },
avatar: null,
context: {},
assignedAt: new Date(),
},
} as Subscriber,
skip: {},
attempt: 0,
},
@ -131,7 +119,7 @@ export const conversationFixtures =
});
export const installConversationTypeFixtures = async () => {
const subscribers = await installSubscriberFixtures();
const { subscribers } = await installSubscriberFixtures();
const blocks = await installBlockFixtures();
const Conversation = mongoose.model(

View File

@ -26,6 +26,9 @@ export const subscriberDefaultValues: TSubscriberFixtures['defaultValues'] = {
lastvisit: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
retainedFrom: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
avatar: null,
context: {
vars: {},
},
};
const subscribers: TSubscriberFixtures['values'][] = [