Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/backend/lambdas/donors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Lambda for managing donors.
|--------|------|-------------|
| GET | /health | Health check |
| GET | /donors | |
| GET | /donations | |

## Setup

Expand Down
104 changes: 100 additions & 4 deletions apps/backend/lambdas/donors/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,110 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// >>> 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) {
Expand Down
212 changes: 187 additions & 25 deletions apps/backend/lambdas/donors/test/donors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ const adminUser = {
},
}

function createEvent(method: string, path: string, body?: any) {
function createEvent(method: string, path: string, body?: any, queryStringParameters?: Record<string, string>) {
return {
rawPath: path,
requestContext: {
Expand All @@ -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();
Expand Down Expand Up @@ -88,63 +84,229 @@ 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 () => {
mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false });
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();
}
});

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 () => {
Expand Down
Loading