diff --git a/apps/backend/lambdas/donors/README.md b/apps/backend/lambdas/donors/README.md index d28c88b..e48be9d 100644 --- a/apps/backend/lambdas/donors/README.md +++ b/apps/backend/lambdas/donors/README.md @@ -10,6 +10,7 @@ Lambda for managing donors. |--------|------|-------------| | GET | /health | Health check | | GET | /donors | | +| GET | /donations | | ## Setup diff --git a/apps/backend/lambdas/donors/handler.ts b/apps/backend/lambdas/donors/handler.ts index 0b93107..be43cdc 100644 --- a/apps/backend/lambdas/donors/handler.ts +++ b/apps/backend/lambdas/donors/handler.ts @@ -23,14 +23,110 @@ export const handler = async (event: any): Promise => { // >>> ROUTES-START (do not remove this marker) // CLI-generated routes will be inserted here - + // GET /donors if (rawPath === '/' && method === 'GET') { + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.donors') + .select(db.fn.count('donor_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donors = await db + .selectFrom('branch.donors') + .selectAll() + .orderBy('donor_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donors, + pagination: { page, limit, totalItems, totalPages }, + }); + } + + const donors = await db.selectFrom('branch.donors').selectAll().execute(); + return json(200, { data: donors }); + } + + // GET /donations + if ((normalizedPath === '/donations') && method === 'GET') { + const queryParams = event.queryStringParameters || {}; + const pageStr = queryParams.page as string | undefined; + const limitStr = queryParams.limit as string | undefined; + + if (pageStr !== undefined) { + if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) { + return json(400, { message: 'page must be a positive integer' }); + } + } + + if (limitStr !== undefined) { + if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) { + return json(400, { message: 'limit must be a positive integer' }); + } + } + + const page = pageStr ? parseInt(pageStr, 10) : null; + const limit = limitStr ? parseInt(limitStr, 10) : null; + + if (page && limit) { + const offset = (page - 1) * limit; + + const totalCount = await db + .selectFrom('branch.project_donations') + .select(db.fn.count('donation_id').as('count')) + .executeTakeFirst(); + + const totalItems = Number(totalCount?.count || 0); + const totalPages = Math.ceil(totalItems / limit); + + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .orderBy('donation_id', 'asc') + .limit(limit) + .offset(offset) + .execute(); + + return json(200, { + data: donations, + pagination: { page, limit, totalItems, totalPages }, + }); + } - const donors = await db.selectFrom("branch.donors").selectAll().execute() - return json(200, donors ?? []); + const donations = await db + .selectFrom('branch.project_donations') + .selectAll() + .execute(); + return json(200, { data: donations }); } - // <<< ROUTES-END + // <<< ROUTES-END return json(404, { message: 'Not Found', path: normalizedPath, method }); } catch (err) { diff --git a/apps/backend/lambdas/donors/test/donors.test.ts b/apps/backend/lambdas/donors/test/donors.test.ts index a2b4c96..5429441 100644 --- a/apps/backend/lambdas/donors/test/donors.test.ts +++ b/apps/backend/lambdas/donors/test/donors.test.ts @@ -38,7 +38,7 @@ const adminUser = { }, } -function createEvent(method: string, path: string, body?: any) { +function createEvent(method: string, path: string, body?: any, queryStringParameters?: Record) { return { rawPath: path, requestContext: { @@ -47,14 +47,10 @@ function createEvent(method: string, path: string, body?: any) { }, }, body: body ? JSON.stringify(body) : undefined, + queryStringParameters: queryStringParameters ?? {}, }; } -function createAdminToken() { - mockAuthenticateRequest.mockResolvedValueOnce(adminUser); - return 'admin-token'; -} - describe("Donor API with data", () => { beforeEach(async () => { const client = await pool.connect(); @@ -88,8 +84,8 @@ describe("Donor API with data", () => { const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBe(3); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(3); }); test("401 when missing authorization header", async () => { @@ -97,13 +93,167 @@ describe("Donor API with data", () => { const res = await handler(createEvent('GET', '/')); expect(res.statusCode).toBe(401); }); + + // --- Donors pagination --- + + test("GET /donors with page and limit returns paginated response", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '1', limit: '1' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(1); + expect(body.pagination).toBeDefined(); + expect(body.pagination.page).toBe(1); + expect(body.pagination.limit).toBe(1); + expect(body.pagination.totalItems).toBe(3); + expect(body.pagination.totalPages).toBe(3); + expect(body.data[0].organization).toBe('NIH'); + }); + + test("GET /donors page=2 limit=1 returns second donor", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '2', limit: '1' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.data.length).toBe(1); + expect(body.pagination.page).toBe(2); + expect(body.data[0].organization).toBe('Harvard Medical'); + }); + + test("GET /donors with limit larger than total returns all donors", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '1', limit: '100' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.data.length).toBe(3); + expect(body.pagination.totalItems).toBe(3); + expect(body.pagination.totalPages).toBe(1); + }); + + test("GET /donors with only page returns all donors without pagination", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '1' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.pagination).toBeUndefined(); + expect(body.data.length).toBe(3); + }); + + test("GET /donors with only limit returns all donors without pagination", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { limit: '1' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.pagination).toBeUndefined(); + expect(body.data.length).toBe(3); + }); + + test("GET /donors returns 400 for page=0", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '0', limit: '10' })); + expect(res.statusCode).toBe(400); + }); + + test("GET /donors returns 400 for negative page", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '-1', limit: '10' })); + expect(res.statusCode).toBe(400); + }); + + test("GET /donors returns 400 for non-integer page", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: 'abc', limit: '10' })); + expect(res.statusCode).toBe(400); + }); + + test("GET /donors returns 400 for limit=0", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '1', limit: '0' })); + expect(res.statusCode).toBe(400); + }); + + test("GET /donors returns 400 for non-integer limit", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/', undefined, { page: '1', limit: 'abc' })); + expect(res.statusCode).toBe(400); + }); + + // --- Donations endpoint --- + + test("GET /donations returns 200 with data array", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations')); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(3); + }); + + test("GET /donations with page and limit returns paginated response", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations', undefined, { page: '1', limit: '1' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.data.length).toBe(1); + expect(body.pagination).toBeDefined(); + expect(body.pagination.page).toBe(1); + expect(body.pagination.limit).toBe(1); + expect(body.pagination.totalItems).toBe(3); + expect(body.pagination.totalPages).toBe(3); + }); + + test("GET /donations with only page returns all without pagination", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations', undefined, { page: '1' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.pagination).toBeUndefined(); + expect(body.data.length).toBe(3); + }); + + test("GET /donations with only limit returns all without pagination", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations', undefined, { limit: '2' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.pagination).toBeUndefined(); + expect(body.data.length).toBe(3); + }); + + test("GET /donations returns 400 for page=0", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations', undefined, { page: '0', limit: '10' })); + expect(res.statusCode).toBe(400); + }); + + test("GET /donations returns 400 for non-integer limit", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations', undefined, { page: '1', limit: '1.5' })); + expect(res.statusCode).toBe(400); + }); + + test("GET /donations returns 401 when unauthenticated", async () => { + mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false }); + const res = await handler(createEvent('GET', '/donations')); + expect(res.statusCode).toBe(401); + }); }); describe("Donor API when DB is empty", () => { beforeEach(async () => { const client = await pool.connect(); try { - await client.query('TRUNCATE TABLE donors RESTART IDENTITY CASCADE;'); + await client.query('TRUNCATE TABLE branch.donors RESTART IDENTITY CASCADE;'); } finally { client.release(); } @@ -111,40 +261,52 @@ describe("Donor API when DB is empty", () => { test("Status check for get all donors when DB is empty - with auth", async () => { mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); - const res = await handler( - createEvent('GET', '/') - ); + const res = await handler(createEvent('GET', '/')); expect(res.statusCode).toBe(200); }); - test("Status check for get all donors when DB is empty - with auth", async () => { + test("Status check for get all donors when DB is empty - with admin", async () => { mockAuthenticateRequest.mockResolvedValueOnce(adminUser); - const res = await handler( - createEvent('GET', '/') - ); + const res = await handler(createEvent('GET', '/')); expect(res.statusCode).toBe(200); }); test("Content check for get all donors when DB is empty - with auth", async () => { mockAuthenticateRequest.mockResolvedValueOnce(adminUser); - const res = await handler( - createEvent('GET', '/') - ); + const res = await handler(createEvent('GET', '/')); const body = JSON.parse(res.body); expect(res.statusCode).toBe(200); - expect(Array.isArray(body)).toBe(true); - expect(body.length).toBe(0); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(0); }); test("401 when missing authentication", async () => { mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false }); - const res = await handler( - createEvent('GET', '/') - ); - + const res = await handler(createEvent('GET', '/')); expect(res.statusCode).toBe(401); }); + + test("GET /donations returns empty data when DB is empty", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations')); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(0); + }); + + test("GET /donations paginated returns 0 totalItems when DB is empty", async () => { + mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); + const res = await handler(createEvent('GET', '/donations', undefined, { page: '1', limit: '10' })); + const body = JSON.parse(res.body); + + expect(res.statusCode).toBe(200); + expect(body.data.length).toBe(0); + expect(body.pagination.totalItems).toBe(0); + expect(body.pagination.totalPages).toBe(0); + }); }); afterAll(async () => {