diff --git a/api/src/chat/controllers/subscriber.controller.spec.ts b/api/src/chat/controllers/subscriber.controller.spec.ts index 32361be8..9c514302 100644 --- a/api/src/chat/controllers/subscriber.controller.spec.ts +++ b/api/src/chat/controllers/subscriber.controller.spec.ts @@ -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, }); }); }); diff --git a/api/src/chat/repositories/subscriber.repository.spec.ts b/api/src/chat/repositories/subscriber.repository.spec.ts index b034ae4a..e34dee65 100644 --- a/api/src/chat/repositories/subscriber.repository.spec.ts +++ b/api/src/chat/repositories/subscriber.repository.spec.ts @@ -119,6 +119,7 @@ describe('SubscriberRepository', () => { subscriber.labels.includes(label.id), ), assignedTo: allUsers.find(({ id }) => subscriber.assignedTo === id), + context: undefined, }; expect(subscriberModel.findById).toHaveBeenCalledWith( diff --git a/api/src/chat/services/conversation.service.spec.ts b/api/src/chat/services/conversation.service.spec.ts new file mode 100644 index 00000000..e3a8ebc5 --- /dev/null +++ b/api/src/chat/services/conversation.service.spec.ts @@ -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; + + 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; + + 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; + + 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; + + 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; + + // Second call to ensure the offset keeps growing + const result2 = await conversationService.storeContextData( + conversation, + next, + event2, + ); + + expect(result2.context.skip['block-1']).toBe(10); + }); + }); +}); diff --git a/api/src/utils/test/fixtures/block.ts b/api/src/utils/test/fixtures/block.ts index 5698f02f..06748a00 100644 --- a/api/src/utils/test/fixtures/block.ts +++ b/api/src/utils/test/fixtures/block.ts @@ -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; }; diff --git a/api/src/utils/test/fixtures/contextvar.ts b/api/src/utils/test/fixtures/contextvar.ts index 6cb188f9..536f5a3a 100644 --- a/api/src/utils/test/fixtures/contextvar.ts +++ b/api/src/utils/test/fixtures/contextvar.ts @@ -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', diff --git a/api/src/utils/test/fixtures/conversation.ts b/api/src/utils/test/fixtures/conversation.ts index 5550eeab..b2a15733 100644 --- a/api/src/utils/test/fixtures/conversation.ts +++ b/api/src/utils/test/fixtures/conversation.ts @@ -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( diff --git a/api/src/utils/test/fixtures/subscriber.ts b/api/src/utils/test/fixtures/subscriber.ts index 0f11f32d..22caa4a3 100644 --- a/api/src/utils/test/fixtures/subscriber.ts +++ b/api/src/utils/test/fixtures/subscriber.ts @@ -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'][] = [