Skip to content

Commit 466d7b7

Browse files
committed
test(api): add E2E tests for agent webhook and reply flows
NV-7356 Made-with: Cursor
1 parent ee04877 commit 466d7b7

File tree

4 files changed

+838
-0
lines changed

4 files changed

+838
-0
lines changed
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
import {
2+
ConversationActivitySenderTypeEnum,
3+
ConversationActivityTypeEnum,
4+
ConversationRepository,
5+
ConversationStatusEnum,
6+
} from '@novu/dal';
7+
import { expect } from 'chai';
8+
import { testServer } from '@novu/testing';
9+
import { BridgeExecutorService, BridgeExecutorParams } from '../services/bridge-executor.service';
10+
import { ChatSdkService } from '../services/chat-sdk.service';
11+
import {
12+
setupAgentTestContext,
13+
seedConversation,
14+
conversationRepository,
15+
activityRepository,
16+
AgentTestContext,
17+
} from './helpers/agent-test-setup';
18+
19+
describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
20+
let ctx: AgentTestContext;
21+
let chatSdkService: ChatSdkService;
22+
let bridgeExecutor: BridgeExecutorService;
23+
let bridgeCalls: BridgeExecutorParams[];
24+
let originalBridgeExecute: BridgeExecutorService['execute'];
25+
let originalPostToConversation: ChatSdkService['postToConversation'];
26+
27+
before(() => {
28+
process.env.IS_CONVERSATIONAL_AGENTS_ENABLED = 'true';
29+
});
30+
31+
beforeEach(async () => {
32+
ctx = await setupAgentTestContext();
33+
chatSdkService = testServer.getService(ChatSdkService);
34+
bridgeExecutor = testServer.getService(BridgeExecutorService);
35+
36+
bridgeCalls = [];
37+
originalBridgeExecute = bridgeExecutor.execute.bind(bridgeExecutor);
38+
bridgeExecutor.execute = async (params: BridgeExecutorParams) => {
39+
bridgeCalls.push(params);
40+
};
41+
42+
originalPostToConversation = chatSdkService.postToConversation.bind(chatSdkService);
43+
chatSdkService.postToConversation = async () => {};
44+
});
45+
46+
afterEach(() => {
47+
bridgeExecutor.execute = originalBridgeExecute;
48+
chatSdkService.postToConversation = originalPostToConversation;
49+
});
50+
51+
function postReply(body: Record<string, unknown>) {
52+
return ctx.session.testAgent
53+
.post(`/v1/agents/${ctx.agentIdentifier}/reply`)
54+
.send(body);
55+
}
56+
57+
describe('Delivery and persistence', () => {
58+
it('should persist agent reply activity and increment messageCount', async () => {
59+
const conversationId = await seedConversation(ctx);
60+
const convBefore = await conversationRepository.findOne(
61+
{ _id: conversationId, _environmentId: ctx.session.environment._id },
62+
'*'
63+
);
64+
const countBefore = convBefore!.messageCount;
65+
66+
const res = await postReply({
67+
conversationId,
68+
integrationIdentifier: ctx.integrationIdentifier,
69+
reply: { text: 'Hello from agent' },
70+
});
71+
72+
expect(res.status).to.equal(200);
73+
expect(res.body.data.status).to.equal('ok');
74+
75+
const convAfter = await conversationRepository.findOne(
76+
{ _id: conversationId, _environmentId: ctx.session.environment._id },
77+
'*'
78+
);
79+
expect(convAfter!.messageCount).to.equal(countBefore + 1);
80+
expect(convAfter!.lastMessagePreview).to.equal('Hello from agent');
81+
82+
const activities = await activityRepository.findByConversation(
83+
ctx.session.environment._id,
84+
conversationId
85+
);
86+
const agentActivity = activities.find(
87+
(a) => a.senderType === ConversationActivitySenderTypeEnum.AGENT && a.type === ConversationActivityTypeEnum.MESSAGE
88+
);
89+
expect(agentActivity).to.exist;
90+
expect(agentActivity!.content).to.equal('Hello from agent');
91+
});
92+
93+
it('should persist update activity and return early without executing resolve', async () => {
94+
const conversationId = await seedConversation(ctx);
95+
96+
const res = await postReply({
97+
conversationId,
98+
integrationIdentifier: ctx.integrationIdentifier,
99+
update: { text: 'Processing...' },
100+
resolve: { summary: 'Should be ignored' },
101+
});
102+
103+
expect(res.status).to.equal(200);
104+
expect(res.body.data.status).to.equal('update_sent');
105+
106+
const activities = await activityRepository.findByConversation(
107+
ctx.session.environment._id,
108+
conversationId
109+
);
110+
const updateActivity = activities.find((a) => a.type === ConversationActivityTypeEnum.UPDATE);
111+
expect(updateActivity).to.exist;
112+
expect(updateActivity!.content).to.equal('Processing...');
113+
114+
const conversation = await conversationRepository.findOne(
115+
{ _id: conversationId, _environmentId: ctx.session.environment._id },
116+
'*'
117+
);
118+
expect(conversation!.status).to.equal(ConversationStatusEnum.ACTIVE);
119+
});
120+
121+
it('should reject when both reply and update are provided', async () => {
122+
const conversationId = await seedConversation(ctx);
123+
124+
const res = await postReply({
125+
conversationId,
126+
integrationIdentifier: ctx.integrationIdentifier,
127+
reply: { text: 'a' },
128+
update: { text: 'b' },
129+
});
130+
131+
expect(res.status).to.equal(400);
132+
});
133+
134+
it('should return 400 when conversation has no serialized thread', async () => {
135+
const conversationId = await seedConversation(ctx, { withSerializedThread: false });
136+
137+
const res = await postReply({
138+
conversationId,
139+
integrationIdentifier: ctx.integrationIdentifier,
140+
reply: { text: 'Should fail' },
141+
});
142+
143+
expect(res.status).to.equal(400);
144+
});
145+
});
146+
147+
describe('Signals (metadata)', () => {
148+
it('should merge metadata signals into conversation.metadata and persist signal activity', async () => {
149+
const conversationId = await seedConversation(ctx);
150+
151+
const res = await postReply({
152+
conversationId,
153+
integrationIdentifier: ctx.integrationIdentifier,
154+
signals: [{ type: 'metadata', key: 'sentiment', value: 'positive' }],
155+
});
156+
157+
expect(res.status).to.equal(200);
158+
expect(res.body.data.status).to.equal('ok');
159+
160+
const conversation = await conversationRepository.findOne(
161+
{ _id: conversationId, _environmentId: ctx.session.environment._id },
162+
'*'
163+
);
164+
expect(conversation!.metadata).to.have.property('sentiment', 'positive');
165+
166+
const activities = await activityRepository.findByConversation(
167+
ctx.session.environment._id,
168+
conversationId
169+
);
170+
const signalActivity = activities.find(
171+
(a) =>
172+
a.type === ConversationActivityTypeEnum.SIGNAL &&
173+
a.senderType === ConversationActivitySenderTypeEnum.SYSTEM
174+
);
175+
expect(signalActivity).to.exist;
176+
expect(signalActivity!.signalData).to.exist;
177+
expect(signalActivity!.signalData!.type).to.equal('metadata');
178+
});
179+
180+
it('should reject when cumulative metadata exceeds 64KB', async () => {
181+
const bigValue = 'x'.repeat(60_000);
182+
const conversationId = await seedConversation(ctx, {
183+
metadata: { existingBigKey: bigValue },
184+
});
185+
186+
const res = await postReply({
187+
conversationId,
188+
integrationIdentifier: ctx.integrationIdentifier,
189+
signals: [{ type: 'metadata', key: 'overflow', value: 'x'.repeat(6_000) }],
190+
});
191+
192+
expect(res.status).to.equal(400);
193+
});
194+
});
195+
196+
describe('Resolve', () => {
197+
it('should resolve conversation and fire onResolve bridge callback', async () => {
198+
const conversationId = await seedConversation(ctx);
199+
200+
const res = await postReply({
201+
conversationId,
202+
integrationIdentifier: ctx.integrationIdentifier,
203+
resolve: { summary: 'Issue fixed' },
204+
});
205+
206+
expect(res.status).to.equal(200);
207+
expect(res.body.data.status).to.equal('ok');
208+
209+
const conversation = await conversationRepository.findOne(
210+
{ _id: conversationId, _environmentId: ctx.session.environment._id },
211+
'*'
212+
);
213+
expect(conversation!.status).to.equal(ConversationStatusEnum.RESOLVED);
214+
215+
const activities = await activityRepository.findByConversation(
216+
ctx.session.environment._id,
217+
conversationId
218+
);
219+
const resolveActivity = activities.find(
220+
(a) => a.type === ConversationActivityTypeEnum.SIGNAL && a.signalData?.type === 'resolve'
221+
);
222+
expect(resolveActivity).to.exist;
223+
expect(resolveActivity!.content).to.contain('Issue fixed');
224+
225+
// onResolve bridge call is fire-and-forget; give it a moment
226+
await new Promise((resolve) => setTimeout(resolve, 100));
227+
228+
expect(bridgeCalls.length).to.be.gte(1);
229+
const resolveCall = bridgeCalls.find((c) => c.event === 'onResolve');
230+
expect(resolveCall).to.exist;
231+
});
232+
233+
it('should handle reply + signals + resolve in a single request', async () => {
234+
const conversationId = await seedConversation(ctx);
235+
const convBefore = await conversationRepository.findOne(
236+
{ _id: conversationId, _environmentId: ctx.session.environment._id },
237+
'*'
238+
);
239+
240+
const res = await postReply({
241+
conversationId,
242+
integrationIdentifier: ctx.integrationIdentifier,
243+
reply: { text: 'Here is your answer' },
244+
signals: [{ type: 'metadata', key: 'resolved_by', value: 'bot' }],
245+
resolve: { summary: 'Answered' },
246+
});
247+
248+
expect(res.status).to.equal(200);
249+
expect(res.body.data.status).to.equal('ok');
250+
251+
const convAfter = await conversationRepository.findOne(
252+
{ _id: conversationId, _environmentId: ctx.session.environment._id },
253+
'*'
254+
);
255+
expect(convAfter!.messageCount).to.equal(convBefore!.messageCount + 1);
256+
expect(convAfter!.metadata).to.have.property('resolved_by', 'bot');
257+
expect(convAfter!.status).to.equal(ConversationStatusEnum.RESOLVED);
258+
259+
const activities = await activityRepository.findByConversation(
260+
ctx.session.environment._id,
261+
conversationId
262+
);
263+
264+
const messageActivity = activities.find(
265+
(a) => a.type === ConversationActivityTypeEnum.MESSAGE && a.senderType === ConversationActivitySenderTypeEnum.AGENT
266+
);
267+
expect(messageActivity).to.exist;
268+
expect(messageActivity!.content).to.equal('Here is your answer');
269+
270+
const metadataActivity = activities.find(
271+
(a) => a.type === ConversationActivityTypeEnum.SIGNAL && a.signalData?.type === 'metadata'
272+
);
273+
expect(metadataActivity).to.exist;
274+
275+
const resolveActivity = activities.find(
276+
(a) => a.type === ConversationActivityTypeEnum.SIGNAL && a.signalData?.type === 'resolve'
277+
);
278+
expect(resolveActivity).to.exist;
279+
});
280+
});
281+
});

0 commit comments

Comments
 (0)