Skip to content

Commit 2131784

Browse files
committed
build tests
1 parent 4fd0e10 commit 2131784

File tree

2 files changed

+315
-13
lines changed

2 files changed

+315
-13
lines changed

src/api/routes/events.ts

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,27 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
124124
const upcomingOnly = request.query?.upcomingOnly || false;
125125
const host = request.query?.host;
126126
const ts = request.query?.ts; // we only use this to disable cache control
127+
127128
try {
129+
const ifNoneMatch = request.headers["if-none-match"];
130+
if (ifNoneMatch) {
131+
const etag = await getCacheCounter(
132+
fastify.dynamoClient,
133+
"events-etag-all",
134+
);
135+
136+
if (
137+
ifNoneMatch === `"${etag.toString()}"` ||
138+
ifNoneMatch === etag.toString()
139+
) {
140+
return reply
141+
.code(304)
142+
.header("ETag", etag)
143+
.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY)
144+
.send();
145+
}
146+
reply.header("etag", etag);
147+
}
128148
let command;
129149
if (host) {
130150
command = new QueryCommand({
@@ -142,11 +162,14 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
142162
TableName: genericConfig.EventsDynamoTableName,
143163
});
144164
}
145-
const etag = await getCacheCounter(
146-
fastify.dynamoClient,
147-
"events-etag-all",
148-
);
149-
reply.header("etag", etag);
165+
if (!ifNoneMatch) {
166+
const etag = await getCacheCounter(
167+
fastify.dynamoClient,
168+
"events-etag-all",
169+
);
170+
reply.header("etag", etag);
171+
}
172+
150173
const response = await fastify.dynamoClient.send(command);
151174
const items = response.Items?.map((item) => unmarshall(item));
152175
const currentTimeChicago = moment().tz("America/Chicago");
@@ -386,7 +409,30 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
386409
async (request: FastifyRequest<EventGetRequest>, reply) => {
387410
const id = request.params.id;
388411
const ts = request.query?.ts;
412+
389413
try {
414+
// Check If-None-Match header
415+
const ifNoneMatch = request.headers["if-none-match"];
416+
if (ifNoneMatch) {
417+
const etag = await getCacheCounter(
418+
fastify.dynamoClient,
419+
`events-etag-${id}`,
420+
);
421+
422+
if (
423+
ifNoneMatch === `"${etag.toString()}"` ||
424+
ifNoneMatch === etag.toString()
425+
) {
426+
return reply
427+
.code(304)
428+
.header("ETag", etag)
429+
.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY)
430+
.send();
431+
}
432+
433+
reply.header("etag", etag);
434+
}
435+
390436
const response = await fastify.dynamoClient.send(
391437
new GetItemCommand({
392438
TableName: genericConfig.EventsDynamoTableName,
@@ -401,11 +447,17 @@ const eventsPlugin: FastifyPluginAsync = async (fastify, _options) => {
401447
if (!ts) {
402448
reply.header("Cache-Control", CLIENT_HTTP_CACHE_POLICY);
403449
}
404-
const etag = await getCacheCounter(
405-
fastify.dynamoClient,
406-
`events-etag-${id}`,
407-
);
408-
return reply.header("etag", etag).send(item);
450+
451+
// Only get the etag now if we didn't already get it above
452+
if (!ifNoneMatch) {
453+
const etag = await getCacheCounter(
454+
fastify.dynamoClient,
455+
`events-etag-${id}`,
456+
);
457+
reply.header("etag", etag);
458+
}
459+
460+
return reply.send(item);
409461
} catch (e) {
410462
if (e instanceof BaseError) {
411463
throw e;

tests/unit/events.test.ts

Lines changed: 253 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,8 @@ const jwt_secret = secretObject["jwt_key"];
2222
vi.stubEnv("JwtSigningKey", jwt_secret);
2323

2424
// Mock the Discord client to prevent the actual Discord API call
25-
vi.mock("../../src/api/functions/discord.js", async (importOriginal) => {
26-
const mod = await importOriginal();
25+
vi.mock("../../src/api/functions/discord.js", async () => {
2726
return {
28-
...mod,
2927
updateDiscord: vi.fn().mockResolvedValue({}),
3028
};
3129
});
@@ -120,6 +118,258 @@ test("ETag should increment after event creation", async () => {
120118
expect(specificEventResponse.headers.etag).toBe("1");
121119
});
122120

121+
test("Should return 304 Not Modified when If-None-Match header matches ETag", async () => {
122+
// Setup
123+
(app as any).nodeCache.flushAll();
124+
ddbMock.reset();
125+
smMock.reset();
126+
vi.useFakeTimers();
127+
128+
// Mock secrets manager
129+
smMock.on(GetSecretValueCommand).resolves({
130+
SecretString: secretJson,
131+
});
132+
133+
// Mock successful DynamoDB operations
134+
ddbMock.on(PutItemCommand).resolves({});
135+
136+
// Mock ScanCommand to return empty Items array
137+
ddbMock.on(ScanCommand).resolves({
138+
Items: [],
139+
});
140+
141+
const testJwt = createJwt(undefined, "0");
142+
143+
// 1. First GET request to establish ETag
144+
const initialResponse = await app.inject({
145+
method: "GET",
146+
url: "/api/v1/events",
147+
headers: {
148+
Authorization: `Bearer ${testJwt}`,
149+
},
150+
});
151+
152+
expect(initialResponse.statusCode).toBe(200);
153+
expect(initialResponse.headers.etag).toBe("0");
154+
155+
// 2. Second GET request with If-None-Match header matching the ETag
156+
const conditionalResponse = await app.inject({
157+
method: "GET",
158+
url: "/api/v1/events",
159+
headers: {
160+
Authorization: `Bearer ${testJwt}`,
161+
"If-None-Match": "0",
162+
},
163+
});
164+
165+
// Expect 304 Not Modified
166+
expect(conditionalResponse.statusCode).toBe(304);
167+
expect(conditionalResponse.headers.etag).toBe("0");
168+
expect(conditionalResponse.body).toBe(""); // Empty body on 304
169+
});
170+
171+
test("Should return 304 Not Modified when If-None-Match header matches quoted ETag", async () => {
172+
// Setup
173+
(app as any).nodeCache.flushAll();
174+
ddbMock.reset();
175+
smMock.reset();
176+
vi.useFakeTimers();
177+
178+
// Mock secrets manager
179+
smMock.on(GetSecretValueCommand).resolves({
180+
SecretString: secretJson,
181+
});
182+
183+
// Mock successful DynamoDB operations
184+
ddbMock.on(PutItemCommand).resolves({});
185+
186+
// Mock ScanCommand to return empty Items array
187+
ddbMock.on(ScanCommand).resolves({
188+
Items: [],
189+
});
190+
191+
const testJwt = createJwt(undefined, "0");
192+
193+
// 1. First GET request to establish ETag
194+
const initialResponse = await app.inject({
195+
method: "GET",
196+
url: "/api/v1/events",
197+
headers: {
198+
Authorization: `Bearer ${testJwt}`,
199+
},
200+
});
201+
202+
expect(initialResponse.statusCode).toBe(200);
203+
expect(initialResponse.headers.etag).toBe("0");
204+
205+
// 2. Second GET request with quoted If-None-Match header
206+
const conditionalResponse = await app.inject({
207+
method: "GET",
208+
url: "/api/v1/events",
209+
headers: {
210+
Authorization: `Bearer ${testJwt}`,
211+
"If-None-Match": '"0"',
212+
},
213+
});
214+
215+
// Expect 304 Not Modified
216+
expect(conditionalResponse.statusCode).toBe(304);
217+
expect(conditionalResponse.headers.etag).toBe("0");
218+
expect(conditionalResponse.body).toBe(""); // Empty body on 304
219+
});
220+
221+
test("Should NOT return 304 when ETag has changed", async () => {
222+
// Setup
223+
(app as any).nodeCache.flushAll();
224+
ddbMock.reset();
225+
smMock.reset();
226+
vi.useFakeTimers();
227+
228+
// Mock secrets manager
229+
smMock.on(GetSecretValueCommand).resolves({
230+
SecretString: secretJson,
231+
});
232+
233+
// Mock successful DynamoDB operations
234+
ddbMock.on(PutItemCommand).resolves({});
235+
236+
// Mock ScanCommand to return empty Items array
237+
ddbMock.on(ScanCommand).resolves({
238+
Items: [],
239+
});
240+
241+
const testJwt = createJwt(undefined, "0");
242+
243+
// 1. Initial GET to establish ETag
244+
const initialResponse = await app.inject({
245+
method: "GET",
246+
url: "/api/v1/events",
247+
headers: {
248+
Authorization: `Bearer ${testJwt}`,
249+
},
250+
});
251+
252+
expect(initialResponse.statusCode).toBe(200);
253+
expect(initialResponse.headers.etag).toBe("0");
254+
255+
// 2. Create a new event to change the ETag
256+
const eventResponse = await supertest(app.server)
257+
.post("/api/v1/events")
258+
.set("Authorization", `Bearer ${testJwt}`)
259+
.send({
260+
description: "Test event to change ETag",
261+
host: "Social Committee",
262+
location: "Siebel Center",
263+
start: "2024-09-25T18:00:00",
264+
title: "ETag Change Test",
265+
featured: false,
266+
});
267+
268+
expect(eventResponse.statusCode).toBe(201);
269+
const eventId = eventResponse.body.id;
270+
271+
// Mock GetItemCommand to return the event we just created
272+
ddbMock.on(GetItemCommand).resolves({
273+
Item: marshall({
274+
id: eventId,
275+
title: "ETag Change Test",
276+
description: "Test event to change ETag",
277+
host: "Social Committee",
278+
location: "Siebel Center",
279+
start: "2024-09-25T18:00:00",
280+
featured: false,
281+
}),
282+
});
283+
284+
// 3. Make conditional request with old ETag
285+
const conditionalResponse = await app.inject({
286+
method: "GET",
287+
url: "/api/v1/events",
288+
headers: {
289+
Authorization: `Bearer ${testJwt}`,
290+
"If-None-Match": "0",
291+
},
292+
});
293+
294+
// Expect 200 OK (not 304) since ETag has changed
295+
expect(conditionalResponse.statusCode).toBe(200);
296+
expect(conditionalResponse.headers.etag).toBe("1");
297+
expect(conditionalResponse.body).not.toBe(""); // Should have body content
298+
});
299+
300+
test("Should handle 304 responses for individual event endpoints", async () => {
301+
// Setup
302+
(app as any).nodeCache.flushAll();
303+
ddbMock.reset();
304+
smMock.reset();
305+
vi.useFakeTimers();
306+
307+
// Mock secrets manager
308+
smMock.on(GetSecretValueCommand).resolves({
309+
SecretString: secretJson,
310+
});
311+
312+
// Mock successful DynamoDB operations
313+
ddbMock.on(PutItemCommand).resolves({});
314+
315+
// Create an event
316+
const testJwt = createJwt(undefined, "0");
317+
const eventResponse = await supertest(app.server)
318+
.post("/api/v1/events")
319+
.set("Authorization", `Bearer ${testJwt}`)
320+
.send({
321+
description: "Individual event test",
322+
host: "Social Committee",
323+
location: "Siebel Center",
324+
start: "2024-09-25T18:00:00",
325+
title: "ETag Individual Test",
326+
featured: false,
327+
});
328+
329+
expect(eventResponse.statusCode).toBe(201);
330+
const eventId = eventResponse.body.id;
331+
332+
// Mock GetItemCommand to return the event
333+
ddbMock.on(GetItemCommand).resolves({
334+
Item: marshall({
335+
id: eventId,
336+
title: "ETag Individual Test",
337+
description: "Individual event test",
338+
host: "Social Committee",
339+
location: "Siebel Center",
340+
start: "2024-09-25T18:00:00",
341+
featured: false,
342+
}),
343+
});
344+
345+
// 1. First GET to establish ETag
346+
const initialEventResponse = await app.inject({
347+
method: "GET",
348+
url: `/api/v1/events/${eventId}`,
349+
headers: {
350+
Authorization: `Bearer ${testJwt}`,
351+
},
352+
});
353+
354+
expect(initialEventResponse.statusCode).toBe(200);
355+
expect(initialEventResponse.headers.etag).toBe("1");
356+
357+
// 2. Second GET with matching If-None-Match
358+
const conditionalEventResponse = await app.inject({
359+
method: "GET",
360+
url: `/api/v1/events/${eventId}`,
361+
headers: {
362+
Authorization: `Bearer ${testJwt}`,
363+
"If-None-Match": "1",
364+
},
365+
});
366+
367+
// Expect 304 Not Modified
368+
expect(conditionalEventResponse.statusCode).toBe(304);
369+
expect(conditionalEventResponse.headers.etag).toBe("1");
370+
expect(conditionalEventResponse.body).toBe("");
371+
});
372+
123373
afterAll(async () => {
124374
await app.close();
125375
vi.useRealTimers();

0 commit comments

Comments
 (0)