Skip to content

Commit c08fc8d

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

File tree

4 files changed

+827
-0
lines changed

4 files changed

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

0 commit comments

Comments
 (0)