diff --git a/api/src/daily-coding-challenge/routes/daily-coding-challenge.test.ts b/api/src/daily-coding-challenge/routes/daily-coding-challenge.test.ts index ad903d77d10..2fe0baec688 100644 --- a/api/src/daily-coding-challenge/routes/daily-coding-challenge.test.ts +++ b/api/src/daily-coding-challenge/routes/daily-coding-challenge.test.ts @@ -92,18 +92,29 @@ describe('/daily-coding-challenge', () => { }); it('should return 400 for an invalid date format', async () => { - const res = await superRequest( - '/daily-coding-challenge/date/invalid-format', - { - method: 'GET' - } - ).send({}); + const invalidFormats = [ + 'invalid-format', + '2025-07', + '07-18-2025', + '25-07-18', + '2025-7-18', + '2025-07-8' + ]; - expect(res.status).toBe(400); - expect(res.body).toEqual({ - type: 'error', - message: 'Invalid date format. Please use YYYY-MM-DD.' - }); + for (const invalidFormat of invalidFormats) { + const res = await superRequest( + `/daily-coding-challenge/date/${invalidFormat}`, + { + method: 'GET' + } + ).send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + type: 'error', + message: 'Invalid date format. Please use YYYY-MM-DD.' + }); + } }); it('should return 404 for a date without a challenge', async () => { @@ -188,6 +199,82 @@ describe('/daily-coding-challenge', () => { }); }); + describe('GET /daily-coding-challenge/month/:month', () => { + beforeEach(async () => { + await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({ + data: mockChallenges + }); + }); + + afterEach(async () => { + await fastifyTestInstance.prisma.dailyCodingChallenges.deleteMany(); + }); + + it('should return 400 for invalid month format', async () => { + const invalidFormats = ['invalid-month', '2025-13', '2025-1', '25-07']; + + for (const invalidFormat of invalidFormats) { + const res = await superRequest( + `/daily-coding-challenge/month/${invalidFormat}`, + { + method: 'GET' + } + ).send({}); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ + type: 'error', + message: 'Invalid date format. Please use YYYY-MM.' + }); + } + }); + + it('should return { id, date, challengeNumber, title } for all available challenges of the given month up to today US Central', async () => { + const currentMonth = todayUsCentral.toISOString().slice(0, 7); + + const res = await superRequest( + `/daily-coding-challenge/month/${currentMonth}`, + { + method: 'GET' + } + ).send({}); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + + // Should include yesterday's and today's challenges, but not tomorrow's + const expectedResponse = [ + { + id: todaysChallenge.id, + challengeNumber: todaysChallenge.challengeNumber, + date: todaysChallenge.date.toISOString(), + title: todaysChallenge.title + }, + { + id: yesterdaysChallenge.id, + challengeNumber: yesterdaysChallenge.challengeNumber, + date: yesterdaysChallenge.date.toISOString(), + title: yesterdaysChallenge.title + } + ]; + + expect(res.body).toHaveLength(2); + expect(res.body).toEqual(expectedResponse); + }); + + it('should return 404 when no challenges exist for the given month', async () => { + const res = await superRequest('/daily-coding-challenge/month/2024-01', { + method: 'GET' + }).send({}); + + expect(res.status).toBe(404); + expect(res.body).toEqual({ + type: 'error', + message: 'No challenges found.' + }); + }); + }); + describe('GET /daily-coding-challenge/all', () => { beforeEach(async () => { await fastifyTestInstance.prisma.dailyCodingChallenges.createMany({ diff --git a/api/src/daily-coding-challenge/routes/daily-coding-challenge.ts b/api/src/daily-coding-challenge/routes/daily-coding-challenge.ts index 88d080112fc..ba35f16f1f9 100644 --- a/api/src/daily-coding-challenge/routes/daily-coding-challenge.ts +++ b/api/src/daily-coding-challenge/routes/daily-coding-challenge.ts @@ -111,6 +111,79 @@ export const dailyCodingChallengeRoutes: FastifyPluginCallbackTypebox = ( } ); + fastify.get( + '/daily-coding-challenge/month/:month', + { + schema: schemas.dailyCodingChallenge.month + }, + async (req, reply) => { + const logger = fastify.log.child({ req, res: reply }); + logger.info('Received request for month of daily coding challenges', { + month: req.params.month + }); + + const { month } = req.params; + + try { + // Month is guaranteed YYYY-MM format from schema validation + const parts = month.split('-'); + const parsedYear = parseInt(parts[0]!, 10); + const parsedMonth = parseInt(parts[1]!, 10); + + // Validate month range + if (parsedMonth < 1 || parsedMonth > 12) { + logger.warn('Invalid month value requested', { month }); + return reply.status(400).send({ + type: 'error', + message: 'Invalid date format. Please use YYYY-MM.' + }); + } + + const monthStart = new Date(Date.UTC(parsedYear, parsedMonth - 1, 1)); + const monthEnd = new Date(Date.UTC(parsedYear, parsedMonth, 1)); + const todayUsCentral = getUtcMidnight(getNowUsCentral()); + + const challenges = await fastify.prisma.dailyCodingChallenges.findMany({ + where: { + date: { + gte: monthStart, + lt: monthEnd, + lte: todayUsCentral + } + }, + orderBy: { + date: 'desc' + }, + select: { + id: true, + challengeNumber: true, + date: true, + title: true + } + }); + + if (!challenges || challenges.length === 0) { + logger.warn('No challenges found for month', { month }); + return reply + .status(404) + .send({ type: 'error', message: 'No challenges found.' }); + } + + const response = challenges.map(challenge => ({ + ...challenge, + date: challenge.date.toISOString() + })); + + return reply.send(response); + } catch (error) { + logger.error(error, 'Failed to get monthly daily coding challenges.'); + await reply + .status(500) + .send({ type: 'error', message: 'Internal server error.' }); + } + } + ); + fastify.get( '/daily-coding-challenge/all', { diff --git a/api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts b/api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts index b5e52e9dc52..0a23082fb4e 100644 --- a/api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts +++ b/api/src/daily-coding-challenge/schemas/daily-coding-challenge.ts @@ -15,7 +15,7 @@ const challengeLanguage = Type.Object({ ) }); -const singleChallenge = Type.Object({ +const singleChallengeResponse = Type.Object({ id: Type.String({ format: 'objectid', maxLength: 24, minLength: 24 }), date: Type.String({ format: 'date-time' }), challengeNumber: Type.Number(), @@ -30,7 +30,7 @@ const date = { date: Type.String({ format: 'date' }) }), response: { - 200: singleChallenge, + 200: singleChallengeResponse, 400: Type.Object({ type: Type.Literal('error'), message: Type.Literal('Invalid date format. Please use YYYY-MM-DD.') @@ -48,7 +48,7 @@ const date = { const today = { response: { - 200: singleChallenge, + 200: singleChallengeResponse, 404: Type.Object({ type: Type.Literal('error'), message: Type.Literal('Challenge not found.') @@ -60,16 +60,39 @@ const today = { } }; +const manyChallengesResponse = Type.Array( + Type.Object({ + id: Type.String(), + date: Type.String({ format: 'date-time' }), + challengeNumber: Type.Number(), + title: Type.String() + }) +); + +const month = { + params: Type.Object({ + month: Type.String({ pattern: '^\\d{4}-\\d{2}$' }) + }), + response: { + 200: manyChallengesResponse, + 400: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('Invalid date format. Please use YYYY-MM.') + }), + 404: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('No challenges found.') + }), + 500: Type.Object({ + type: Type.Literal('error'), + message: Type.Literal('Internal server error.') + }) + } +}; + const all = { response: { - 200: Type.Array( - Type.Object({ - id: Type.String(), - date: Type.String({ format: 'date-time' }), - challengeNumber: Type.Number(), - title: Type.String() - }) - ), + 200: manyChallengesResponse, 404: Type.Object({ type: Type.Literal('error'), message: Type.Literal('No challenges found.') @@ -100,6 +123,7 @@ const newest = { export const dailyCodingChallenge = { date, today, - newest, - all + month, + all, + newest };