Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/backend/lambdas/donors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Lambda for managing donors.
|--------|------|-------------|
| GET | /health | Health check |
| GET | /donors | |
| GET | /donations | |
| POST | /donations | |
| POST | /donors | |

## Setup
Expand Down
52 changes: 52 additions & 0 deletions apps/backend/lambdas/donors/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,58 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(200, { data: donations });
}

// POST /donations
if (normalizedPath === '/donations' && method === 'POST') {
const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};
const { donor_id, project_id, amount } = body;

if (donor_id === undefined || project_id === undefined || amount === undefined) {
return json(400, { message: 'donor_id, project_id, and amount are required' });
}
if (!Number.isInteger(donor_id) || (donor_id as number) < 1) {
return json(400, { message: 'donor_id must be a positive integer' });
}
if (!Number.isInteger(project_id) || (project_id as number) < 1) {
return json(400, { message: 'project_id must be a positive integer' });
}
if (typeof amount !== 'number' || amount <= 0 || !isFinite(amount)) {
return json(400, { message: 'amount must be a positive number' });
}
// Check user is admin or a member of the project
if (!authContext.user?.isAdmin) {
const userId = authContext.user!.userId as number;
const membership = await db
.selectFrom('branch.project_memberships')
.select('membership_id')
.where('project_id', '=', project_id as number)
.where('user_id', '=', userId)
.executeTakeFirst();

if (!membership) {
return json(403, { message: 'You must be a member' });
}
}

Comment thread
shreeyaadhikari marked this conversation as resolved.
try {
const donation = await db
.insertInto('branch.project_donations')
.values({
donor_id: donor_id as number,
project_id: project_id as number,
amount: amount as number,
})
.returningAll()
.executeTakeFirstOrThrow();

return json(201, { data: donation });
} catch (err: any) {
if (err?.code === '23505') {
return json(409, { message: 'A donation from this donor to this project already exists' });
}
throw err;
}
}

// POST /donors
if ((normalizedPath === '/' || normalizedPath === '/donors') && method === 'POST') {
const body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};
Expand Down
45 changes: 45 additions & 0 deletions apps/backend/lambdas/donors/test/donors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,49 @@ describe("Donor API with data", () => {
const res = await handler(createEvent('GET', '/donations'));
expect(res.statusCode).toBe(401);
});
test("POST /donations returns 201 and created donation", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser);
const res = await handler(createEvent('POST', '/donations', {
donor_id: 2, project_id: 1, amount: 500,
}));
const body = JSON.parse(res.body);
expect(res.statusCode).toBe(201);
expect(body.data.donor_id).toBe(2);
expect(body.data.project_id).toBe(1);
expect(Number(body.data.amount)).toBe(500);
});

test("POST /donations returns 400 when donor_id is missing", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser);
const res = await handler(createEvent('POST', '/donations', { project_id: 1, amount: 100 }));
expect(res.statusCode).toBe(400);
});

test("POST /donations returns 400 when amount is zero", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser);
const res = await handler(createEvent('POST', '/donations', { donor_id: 1, project_id: 1, amount: 0 }));
expect(res.statusCode).toBe(400);
});

test("POST /donations returns 400 when amount is negative", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser);
const res = await handler(createEvent('POST', '/donations', { donor_id: 1, project_id: 1, amount: -50 }));
expect(res.statusCode).toBe(400);
});

test("POST /donations returns 401 when unauthenticated", async () => {
mockAuthenticateRequest.mockResolvedValueOnce({ isAuthenticated: false });
const res = await handler(createEvent('POST', '/donations', { donor_id: 1, project_id: 1, amount: 100 }));
expect(res.statusCode).toBe(401);
});

test("POST /donations returns 403 when user is not a project member", async () => {
mockAuthenticateRequest.mockResolvedValueOnce(authenticatedUser); // userId: 1, not admin
const res = await handler(createEvent('POST', '/donations', {
donor_id: 1, project_id: 3, amount: 100, // user 1 is not a member of project 3
}));
expect(res.statusCode).toBe(403);
});
});

describe("Donor API when DB is empty", () => {
Expand Down Expand Up @@ -312,3 +355,5 @@ describe("Donor API when DB is empty", () => {
afterAll(async () => {
await pool.end();
});


Loading