1- import { afterAll , expect , test , beforeEach , vi } from "vitest" ;
2- import { DynamoDBClient , PutItemCommand } from "@aws-sdk/client-dynamodb" ;
1+ import { afterAll , expect , test , beforeEach , vi , describe } from "vitest" ;
2+ import {
3+ DynamoDBClient ,
4+ GetItemCommand ,
5+ PutItemCommand ,
6+ ScanCommand ,
7+ } from "@aws-sdk/client-dynamodb" ;
38import { mockClient } from "aws-sdk-client-mock" ;
49import init from "../../src/api/index.js" ;
510import { createJwt } from "./auth.test.js" ;
@@ -9,6 +14,7 @@ import {
914} from "@aws-sdk/client-secrets-manager" ;
1015import { secretJson , secretObject } from "./secret.testdata.js" ;
1116import supertest from "supertest" ;
17+ import { marshall } from "@aws-sdk/util-dynamodb" ;
1218
1319const ddbMock = mockClient ( DynamoDBClient ) ;
1420const smMock = mockClient ( SecretsManagerClient ) ;
@@ -17,6 +23,13 @@ vi.stubEnv("JwtSigningKey", jwt_secret);
1723
1824const app = await init ( ) ;
1925
26+ vi . mock ( "../../src/api/functions/discord.js" , ( ) => {
27+ const updateDiscordMock = vi . fn ( ) . mockResolvedValue ( { } ) ;
28+ return {
29+ updateDiscord : updateDiscordMock ,
30+ } ;
31+ } ) ;
32+
2033test ( "Sad path: Not authenticated" , async ( ) => {
2134 await app . ready ( ) ;
2235 const response = await supertest ( app . server ) . post ( "/api/v1/events" ) . send ( {
@@ -192,6 +205,332 @@ test("Happy path: Adding a weekly repeating, non-featured, paid event", async ()
192205 } ) ;
193206} ) ;
194207
208+ describe ( "ETag Lifecycle Tests" , ( ) => {
209+ test ( "ETag should increment after event creation" , async ( ) => {
210+ // Setup
211+ ( app as any ) . nodeCache . flushAll ( ) ;
212+ ddbMock . reset ( ) ;
213+ smMock . reset ( ) ;
214+ vi . useFakeTimers ( ) ;
215+
216+ // Mock secrets manager
217+ smMock . on ( GetSecretValueCommand ) . resolves ( {
218+ SecretString : secretJson ,
219+ } ) ;
220+
221+ // Mock successful DynamoDB operations
222+ ddbMock . on ( PutItemCommand ) . resolves ( { } ) ;
223+
224+ // Mock ScanCommand to return empty Items array
225+ ddbMock . on ( ScanCommand ) . resolves ( {
226+ Items : [ ] ,
227+ } ) ;
228+
229+ const testJwt = createJwt ( undefined , "0" ) ;
230+
231+ // 1. Check initial etag for all events is 0
232+ const initialAllResponse = await app . inject ( {
233+ method : "GET" ,
234+ url : "/api/v1/events" ,
235+ headers : {
236+ Authorization : `Bearer ${ testJwt } ` ,
237+ } ,
238+ } ) ;
239+
240+ expect ( initialAllResponse . statusCode ) . toBe ( 200 ) ;
241+ expect ( initialAllResponse . headers . etag ) . toBe ( "0" ) ;
242+
243+ // 2. Create a new event using supertest
244+ const eventResponse = await supertest ( app . server )
245+ . post ( "/api/v1/events" )
246+ . set ( "Authorization" , `Bearer ${ testJwt } ` )
247+ . send ( {
248+ description : "Test event for ETag verification" ,
249+ host : "Social Committee" ,
250+ location : "Siebel Center" ,
251+ start : "2024-09-25T18:00:00" ,
252+ title : "ETag Test Event" ,
253+ featured : false ,
254+ } ) ;
255+
256+ expect ( eventResponse . statusCode ) . toBe ( 201 ) ;
257+ const eventId = eventResponse . body . id ;
258+
259+ // Mock GetItemCommand to return the event we just created
260+ ddbMock . on ( GetItemCommand ) . resolves ( {
261+ Item : marshall ( {
262+ id : eventId ,
263+ title : "ETag Test Event" ,
264+ description : "Test event for ETag verification" ,
265+ host : "Social Committee" ,
266+ location : "Siebel Center" ,
267+ start : "2024-09-25T18:00:00" ,
268+ featured : false ,
269+ } ) ,
270+ } ) ;
271+
272+ // 3. Check that the all events etag is now 1
273+ const allEventsResponse = await app . inject ( {
274+ method : "GET" ,
275+ url : "/api/v1/events" ,
276+ headers : {
277+ Authorization : `Bearer ${ testJwt } ` ,
278+ } ,
279+ } ) ;
280+
281+ expect ( allEventsResponse . statusCode ) . toBe ( 200 ) ;
282+ expect ( allEventsResponse . headers . etag ) . toBe ( "1" ) ;
283+
284+ // 4. Check that the specific event etag is also 1
285+ const specificEventResponse = await app . inject ( {
286+ method : "GET" ,
287+ url : `/api/v1/events/${ eventId } ` ,
288+ headers : {
289+ Authorization : `Bearer ${ testJwt } ` ,
290+ } ,
291+ } ) ;
292+
293+ expect ( specificEventResponse . statusCode ) . toBe ( 200 ) ;
294+ expect ( specificEventResponse . headers . etag ) . toBe ( "1" ) ;
295+ } ) ;
296+
297+ test ( "ETags should be deleted when events are deleted" , async ( ) => {
298+ // Setup
299+ ( app as any ) . nodeCache . flushAll ( ) ;
300+ ddbMock . reset ( ) ;
301+ smMock . reset ( ) ;
302+ vi . useFakeTimers ( ) ;
303+
304+ // Mock secrets manager
305+ smMock . on ( GetSecretValueCommand ) . resolves ( {
306+ SecretString : secretJson ,
307+ } ) ;
308+
309+ // Mock successful DynamoDB operations
310+ ddbMock . on ( PutItemCommand ) . resolves ( { } ) ;
311+ ddbMock . on ( ScanCommand ) . resolves ( {
312+ Items : [ ] ,
313+ } ) ;
314+
315+ const testJwt = createJwt ( undefined , "0" ) ;
316+
317+ // 1. Create an event
318+ const eventResponse = await supertest ( app . server )
319+ . post ( "/api/v1/events" )
320+ . set ( "Authorization" , `Bearer ${ testJwt } ` )
321+ . send ( {
322+ description : "Test event for deletion" ,
323+ host : "Social Committee" ,
324+ location : "Siebel Center" ,
325+ start : "2024-09-25T18:00:00" ,
326+ title : "Event to delete" ,
327+ featured : false ,
328+ } ) ;
329+
330+ expect ( eventResponse . statusCode ) . toBe ( 201 ) ;
331+ const eventId = eventResponse . body . id ;
332+
333+ // Mock GetItemCommand to return the event
334+ ddbMock . on ( GetItemCommand ) . resolves ( {
335+ Item : marshall ( {
336+ id : eventId ,
337+ title : "Event to delete" ,
338+ description : "Test event for deletion" ,
339+ host : "Social Committee" ,
340+ location : "Siebel Center" ,
341+ start : "2024-09-25T18:00:00" ,
342+ featured : false ,
343+ } ) ,
344+ } ) ;
345+
346+ // 2. Verify the event's etag exists (should be 1)
347+ const eventBeforeDelete = await app . inject ( {
348+ method : "GET" ,
349+ url : `/api/v1/events/${ eventId } ` ,
350+ headers : {
351+ Authorization : `Bearer ${ testJwt } ` ,
352+ } ,
353+ } ) ;
354+
355+ expect ( eventBeforeDelete . statusCode ) . toBe ( 200 ) ;
356+ expect ( eventBeforeDelete . headers . etag ) . toBe ( "1" ) ;
357+
358+ // 3. Delete the event
359+ const deleteResponse = await supertest ( app . server )
360+ . delete ( `/api/v1/events/${ eventId } ` )
361+ . set ( "Authorization" , `Bearer ${ testJwt } ` ) ;
362+
363+ expect ( deleteResponse . statusCode ) . toBe ( 201 ) ;
364+
365+ // 4. Verify the event no longer exists (should return 404)
366+ // Change the mock to return empty response (simulating deleted event)
367+ ddbMock . on ( GetItemCommand ) . resolves ( {
368+ Item : undefined ,
369+ } ) ;
370+
371+ const eventAfterDelete = await app . inject ( {
372+ method : "GET" ,
373+ url : `/api/v1/events/${ eventId } ` ,
374+ headers : {
375+ Authorization : `Bearer ${ testJwt } ` ,
376+ } ,
377+ } ) ;
378+
379+ expect ( eventAfterDelete . statusCode ) . toBe ( 404 ) ;
380+
381+ // 5. Check that all-events etag is incremented to 2
382+ // (1 for creation, 2 for deletion)
383+ const allEventsResponse = await app . inject ( {
384+ method : "GET" ,
385+ url : "/api/v1/events" ,
386+ headers : {
387+ Authorization : `Bearer ${ testJwt } ` ,
388+ } ,
389+ } ) ;
390+
391+ expect ( allEventsResponse . statusCode ) . toBe ( 200 ) ;
392+ expect ( allEventsResponse . headers . etag ) . toBe ( "2" ) ;
393+ } ) ;
394+
395+ test ( "ETags for different events should be independent" , async ( ) => {
396+ // Setup
397+ ( app as any ) . nodeCache . flushAll ( ) ;
398+ ddbMock . reset ( ) ;
399+ smMock . reset ( ) ;
400+ vi . useFakeTimers ( ) ;
401+
402+ // Mock secrets manager
403+ smMock . on ( GetSecretValueCommand ) . resolves ( {
404+ SecretString : secretJson ,
405+ } ) ;
406+
407+ // Mock successful DynamoDB operations
408+ ddbMock . on ( PutItemCommand ) . resolves ( { } ) ;
409+
410+ // Mock ScanCommand to return empty Items array initially
411+ ddbMock . on ( ScanCommand ) . resolves ( {
412+ Items : [ ] ,
413+ } ) ;
414+
415+ const testJwt = createJwt ( undefined , "0" ) ;
416+
417+ // 1. Check initial etag for all events is 0
418+ const initialAllResponse = await app . inject ( {
419+ method : "GET" ,
420+ url : "/api/v1/events" ,
421+ headers : {
422+ Authorization : `Bearer ${ testJwt } ` ,
423+ } ,
424+ } ) ;
425+
426+ expect ( initialAllResponse . statusCode ) . toBe ( 200 ) ;
427+ expect ( initialAllResponse . headers . etag ) . toBe ( "0" ) ;
428+
429+ // 2. Create first event
430+ const event1Response = await supertest ( app . server )
431+ . post ( "/api/v1/events" )
432+ . set ( "Authorization" , `Bearer ${ testJwt } ` )
433+ . send ( {
434+ description : "First test event" ,
435+ host : "Social Committee" ,
436+ location : "Siebel Center" ,
437+ start : "2024-09-25T18:00:00" ,
438+ title : "Event 1" ,
439+ featured : false ,
440+ } ) ;
441+
442+ expect ( event1Response . statusCode ) . toBe ( 201 ) ;
443+ const event1Id = event1Response . body . id ;
444+
445+ // 3. Create second event
446+ const event2Response = await supertest ( app . server )
447+ . post ( "/api/v1/events" )
448+ . set ( "Authorization" , `Bearer ${ testJwt } ` )
449+ . send ( {
450+ description : "Second test event" ,
451+ host : "Infrastructure Committee" ,
452+ location : "ECEB" ,
453+ start : "2024-09-26T18:00:00" ,
454+ title : "Event 2" ,
455+ featured : false ,
456+ } ) ;
457+
458+ expect ( event2Response . statusCode ) . toBe ( 201 ) ;
459+ const event2Id = event2Response . body . id ;
460+
461+ // Update GetItemCommand mock to handle different events
462+ ddbMock . on ( GetItemCommand ) . callsFake ( ( params ) => {
463+ if ( params . Key && params . Key . id ) {
464+ const eventId = params . Key . id . S ;
465+
466+ if ( eventId === event1Id ) {
467+ return Promise . resolve ( {
468+ Item : marshall ( {
469+ id : event1Id ,
470+ title : "Event 1" ,
471+ description : "First test event" ,
472+ host : "Social Committee" ,
473+ location : "Siebel Center" ,
474+ start : "2024-09-25T18:00:00" ,
475+ featured : false ,
476+ } ) ,
477+ } ) ;
478+ } else if ( eventId === event2Id ) {
479+ return Promise . resolve ( {
480+ Item : marshall ( {
481+ id : event2Id ,
482+ title : "Event 2" ,
483+ description : "Second test event" ,
484+ host : "Infrastructure Committee" ,
485+ location : "ECEB" ,
486+ start : "2024-09-26T18:00:00" ,
487+ featured : false ,
488+ } ) ,
489+ } ) ;
490+ }
491+ }
492+
493+ return Promise . resolve ( { } ) ;
494+ } ) ;
495+
496+ // 4. Check that all events etag is now 2 (incremented twice)
497+ const allEventsResponse = await app . inject ( {
498+ method : "GET" ,
499+ url : "/api/v1/events" ,
500+ headers : {
501+ Authorization : `Bearer ${ testJwt } ` ,
502+ } ,
503+ } ) ;
504+
505+ expect ( allEventsResponse . statusCode ) . toBe ( 200 ) ;
506+ expect ( allEventsResponse . headers . etag ) . toBe ( "2" ) ;
507+
508+ // 5. Check first event etag is 1
509+ const event1Response2 = await app . inject ( {
510+ method : "GET" ,
511+ url : `/api/v1/events/${ event1Id } ` ,
512+ headers : {
513+ Authorization : `Bearer ${ testJwt } ` ,
514+ } ,
515+ } ) ;
516+
517+ expect ( event1Response2 . statusCode ) . toBe ( 200 ) ;
518+ expect ( event1Response2 . headers . etag ) . toBe ( "1" ) ;
519+
520+ // 6. Check second event etag is also 1
521+ const event2Response2 = await app . inject ( {
522+ method : "GET" ,
523+ url : `/api/v1/events/${ event2Id } ` ,
524+ headers : {
525+ Authorization : `Bearer ${ testJwt } ` ,
526+ } ,
527+ } ) ;
528+
529+ expect ( event2Response2 . statusCode ) . toBe ( 200 ) ;
530+ expect ( event2Response2 . headers . etag ) . toBe ( "1" ) ;
531+ } ) ;
532+ } ) ;
533+
195534afterAll ( async ( ) => {
196535 await app . close ( ) ;
197536 vi . useRealTimers ( ) ;
@@ -200,5 +539,6 @@ beforeEach(() => {
200539 ( app as any ) . nodeCache . flushAll ( ) ;
201540 ddbMock . reset ( ) ;
202541 smMock . reset ( ) ;
542+ vi . clearAllMocks ( ) ;
203543 vi . useFakeTimers ( ) ;
204544} ) ;
0 commit comments