13
13
*/
14
14
15
15
import { describe , test , expect , beforeAll , vi } from "vitest" ;
16
+ import { NextRequest } from "next/server" ;
16
17
import prisma from "@/utils/prisma" ;
17
18
import { createEmailProvider } from "@/utils/email/provider" ;
18
19
import type { OutlookProvider } from "@/utils/email/microsoft" ;
20
+ import { webhookBodySchema } from "@/app/api/outlook/webhook/types" ;
19
21
20
22
// ============================================
21
23
// TEST DATA - SET VIA ENVIRONMENT VARIABLES
@@ -24,10 +26,17 @@ const TEST_OUTLOOK_EMAIL = process.env.TEST_OUTLOOK_EMAIL;
24
26
const TEST_CONVERSATION_ID =
25
27
process . env . TEST_CONVERSATION_ID ||
26
28
"AQQkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoAEABuo-fmt9KvQ4u55KlWB32H" ; // Real conversation ID from demoinboxzero@outlook .com
29
+ const TEST_MESSAGE_ID =
30
+ process . env . TEST_MESSAGE_ID ||
31
+ "AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA" ; // Real message ID from demoinboxzero@outlook .com
27
32
const TEST_CATEGORY_NAME = process . env . TEST_CATEGORY_NAME || "To Reply" ;
28
33
29
34
vi . mock ( "server-only" , ( ) => ( { } ) ) ;
30
35
36
+ vi . mock ( "@/utils/redis/message-processing" , ( ) => ( {
37
+ markMessageAsProcessing : vi . fn ( ) . mockResolvedValue ( true ) ,
38
+ } ) ) ;
39
+
31
40
describe . skipIf ( ! TEST_OUTLOOK_EMAIL ) (
32
41
"Outlook Operations Integration Tests" ,
33
42
( ) => {
@@ -275,3 +284,169 @@ describe.skipIf(!TEST_OUTLOOK_EMAIL)(
275
284
} ) ;
276
285
} ,
277
286
) ;
287
+
288
+ // ============================================
289
+ // WEBHOOK PAYLOAD TESTS
290
+ // ============================================
291
+ describe . skipIf ( ! TEST_OUTLOOK_EMAIL ) ( "Outlook Webhook Payload" , ( ) => {
292
+ test ( "should validate real webhook payload structure" , ( ) => {
293
+ const realWebhookPayload = {
294
+ value : [
295
+ {
296
+ subscriptionId : "d2d593e1-9600-4f72-8cd3-dfa04c707f9e" ,
297
+ subscriptionExpirationDateTime : "2025-10-09T15:32:19.8+00:00" ,
298
+ changeType : "updated" ,
299
+ resource :
300
+ "Users/faa95128258c6335/Messages/AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA" ,
301
+ resourceData : {
302
+ "@odata.type" : "#Microsoft.Graph.Message" ,
303
+ "@odata.id" :
304
+ "Users/faa95128258c6335/Messages/AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA" ,
305
+ "@odata.etag" : 'W/"CQAAABYAAAD9/FqFaD10TIpRhdu6azgfAABF+9hk"' ,
306
+ id : "AQMkADAwATNiZmYAZS05YWEAYy1iNWY0LTAwAi0wMAoARgAAA-ybH4V64nRKkgXhv9H-GEkHAP38WoVoPXRMilGF27prOB8AAAIBDAAAAP38WoVoPXRMilGF27prOB8AAABGAqbwAAAA" ,
307
+ } ,
308
+ clientState : "05338492cb69f2facfe870450308f802" ,
309
+ tenantId : "" ,
310
+ } ,
311
+ ] ,
312
+ } ;
313
+
314
+ // Validate against our schema
315
+ const result = webhookBodySchema . safeParse ( realWebhookPayload ) ;
316
+
317
+ expect ( result . success ) . toBe ( true ) ;
318
+ } ) ;
319
+
320
+ test ( "should process webhook and fetch conversationId from message" , async ( ) => {
321
+ // Clean slate: delete any existing executedRules for this message
322
+ const emailAccount = await prisma . emailAccount . findUniqueOrThrow ( {
323
+ where : { email : TEST_OUTLOOK_EMAIL } ,
324
+ } ) ;
325
+
326
+ await prisma . executedRule . deleteMany ( {
327
+ where : {
328
+ emailAccountId : emailAccount . id ,
329
+ messageId : TEST_MESSAGE_ID ,
330
+ } ,
331
+ } ) ;
332
+
333
+ // This test requires a real Outlook account
334
+ const { POST } = await import ( "@/app/api/outlook/webhook/route" ) ;
335
+
336
+ const realWebhookPayload = {
337
+ value : [
338
+ {
339
+ subscriptionId : "d2d593e1-9600-4f72-8cd3-dfa04c707f9e" ,
340
+ subscriptionExpirationDateTime : "2025-10-09T15:32:19.8+00:00" ,
341
+ changeType : "updated" ,
342
+ resource : `Users/faa95128258c6335/Messages/${ TEST_MESSAGE_ID } ` ,
343
+ resourceData : {
344
+ "@odata.type" : "#Microsoft.Graph.Message" ,
345
+ "@odata.id" : `Users/faa95128258c6335/Messages/${ TEST_MESSAGE_ID } ` ,
346
+ "@odata.etag" : 'W/"CQAAABYAAAD9/FqFaD10TIpRhdu6azgfAABF+9hk"' ,
347
+ id : TEST_MESSAGE_ID ,
348
+ } ,
349
+ clientState : process . env . MICROSOFT_WEBHOOK_CLIENT_STATE ,
350
+ tenantId : "" ,
351
+ } ,
352
+ ] ,
353
+ } ;
354
+
355
+ // Create a mock Request object
356
+ const mockRequest = new NextRequest (
357
+ "http://localhost:3000/api/outlook/webhook" ,
358
+ {
359
+ method : "POST" ,
360
+ headers : {
361
+ "Content-Type" : "application/json" ,
362
+ } ,
363
+ body : JSON . stringify ( realWebhookPayload ) ,
364
+ } ,
365
+ ) ;
366
+
367
+ // Call the webhook handler
368
+ const response = await POST ( mockRequest , {
369
+ params : new Promise ( ( ) => ( { } ) ) ,
370
+ } ) ;
371
+
372
+ // Verify webhook processed successfully
373
+ expect ( response . status ) . toBe ( 200 ) ;
374
+
375
+ const responseData = await response . json ( ) ;
376
+ expect ( responseData ) . toEqual ( { ok : true } ) ;
377
+
378
+ console . log ( " ✅ Webhook processed successfully" ) ;
379
+
380
+ // Verify an executedRule was created for this message
381
+ const thirtySecondsAgo = new Date ( Date . now ( ) - 30_000 ) ;
382
+
383
+ const executedRule = await prisma . executedRule . findFirst ( {
384
+ where : {
385
+ messageId : TEST_MESSAGE_ID ,
386
+ createdAt : {
387
+ gte : thirtySecondsAgo ,
388
+ } ,
389
+ } ,
390
+ include : {
391
+ rule : {
392
+ select : {
393
+ name : true ,
394
+ } ,
395
+ } ,
396
+ actionItems : {
397
+ where : {
398
+ draftId : {
399
+ not : null ,
400
+ } ,
401
+ } ,
402
+ } ,
403
+ } ,
404
+ } ) ;
405
+
406
+ expect ( executedRule ) . not . toBeNull ( ) ;
407
+ expect ( executedRule ) . toBeDefined ( ) ;
408
+
409
+ if ( ! executedRule ) {
410
+ throw new Error ( "ExecutedRule is null" ) ;
411
+ }
412
+
413
+ console . log ( " ✅ ExecutedRule created successfully" ) ;
414
+ console . log ( ` Rule: ${ executedRule . rule ?. name || "(no rule)" } ` ) ;
415
+ console . log ( ` Rule ID: ${ executedRule . ruleId || "(no rule id)" } ` ) ;
416
+
417
+ // Check if a draft was created
418
+ const draftAction = executedRule . actionItems . find ( ( a ) => a . draftId ) ;
419
+ if ( draftAction ?. draftId ) {
420
+ const emailAccount = await prisma . emailAccount . findUniqueOrThrow ( {
421
+ where : { email : TEST_OUTLOOK_EMAIL } ,
422
+ } ) ;
423
+
424
+ const provider = ( await createEmailProvider ( {
425
+ emailAccountId : emailAccount . id ,
426
+ provider : "microsoft" ,
427
+ } ) ) as OutlookProvider ;
428
+
429
+ const draft = await provider . getDraft ( draftAction . draftId ) ;
430
+
431
+ expect ( draft ) . toBeDefined ( ) ;
432
+
433
+ // Verify draft is actually a reply, not a fresh draft
434
+ expect ( draft ?. threadId ) . toBeTruthy ( ) ;
435
+ expect ( draft ?. threadId ) . not . toBe ( "" ) ;
436
+
437
+ console . log ( " ✅ Draft created successfully" ) ;
438
+ console . log ( ` Draft ID: ${ draftAction . draftId } ` ) ;
439
+ console . log ( ` Thread ID: ${ draft ?. threadId } ` ) ;
440
+ console . log ( ` Subject: ${ draft ?. subject || "(no subject)" } ` ) ;
441
+ console . log ( " Content:" ) ;
442
+ console . log (
443
+ ` ${ draft ?. textPlain ?. substring ( 0 , 200 ) . replace ( / \n / g, "\n " ) || "(empty)" } ` ,
444
+ ) ;
445
+ if ( draft ?. textPlain && draft . textPlain . length > 200 ) {
446
+ console . log ( ` ... (${ draft . textPlain . length } total characters)` ) ;
447
+ }
448
+ } else {
449
+ console . log ( " ℹ️ No draft action found" ) ;
450
+ }
451
+ } , 30_000 ) ;
452
+ } ) ;
0 commit comments