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
40 changes: 40 additions & 0 deletions apps/backend/lambdas/projects/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,46 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(500, { message: 'Failed to create project' });
}
}

// GET /projects/{id}/expenditures
if (normalizedPath.endsWith('/expenditures') && method === 'GET') {
const pathParts = normalizedPath.split('/').filter(Boolean);

let id: string | undefined;
if (pathParts.length === 3 && pathParts[0] === 'projects') {
id = pathParts[1];
} else if (pathParts.length === 2) {
id = pathParts[0];
}
if (!id) return json(400, { message: 'id is required' });

try {

const project = await db
.selectFrom('branch.projects')
.where('project_id', '=', parseInt(id))
.selectAll()
.executeTakeFirst();

if (!project) {
return json(404, { message: 'Project not found' });
}


const expenditures = await db
.selectFrom('branch.expenditures')
.where('project_id', '=', parseInt(id))
.selectAll()
.orderBy('spent_on', 'desc')
.execute();

return json(200, expenditures);
} catch (err) {
console.error('Database error:', err);
return json(500, { message: 'Failed to fetch expenditures', error: err instanceof Error ? err.message : 'Unknown error' });
}
}

// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
Expand Down
19 changes: 19 additions & 0 deletions apps/backend/lambdas/projects/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,22 @@ paths:
responses:
'200':
description: OK

/projects/{id}/expenditures:
get:
summary: GET /projects/{id}/expenditures
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: OK
content:
application/json:
schema:
type: array
items:
type: object
126 changes: 79 additions & 47 deletions apps/backend/lambdas/projects/test/projects.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,55 @@
// E2E tests require the dev server running at http://localhost:3000/projects
import { handler } from '../handler';
import db from '../db';

const base = 'http://localhost:3000/projects';
beforeAll(() => {
process.env.DB_HOST = process.env.DB_HOST ?? 'localhost';
process.env.DB_PORT = process.env.DB_PORT ?? '5432';
process.env.DB_USER = process.env.DB_USER ?? 'branch_dev';
process.env.DB_PASSWORD = process.env.DB_PASSWORD ?? 'password';
process.env.DB_NAME = process.env.DB_NAME ?? 'branch_db';
});

function postEvent(body: unknown) {
return {
rawPath: '/projects',
requestContext: { http: { method: 'POST' } },
body: JSON.stringify(body),
} as any;
}

function getExpendituresEvent(id: string) {
return {
rawPath: `/projects/${id}/expenditures`,
requestContext: { http: { method: 'GET' } },
} as any;
}

describe('POST /projects (e2e)', () => {
test('201 creates project with number budget', async () => {
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Proj A', total_budget: 1000 }),
});
expect(res.status).toBe(201);
const json = await res.json();
const res = await handler(postEvent({ name: 'Proj A', total_budget: 1000 }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.name).toBe('Proj A');
expect(json.project_id).toBeDefined();
});

test('201 creates project with numeric string budget', async () => {
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Proj B', total_budget: '2500.50' }),
});
expect(res.status).toBe(201);
const json = await res.json();
const res = await handler(postEvent({ name: 'Proj B', total_budget: '2500.50' }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.name).toBe('Proj B');
});

test('201: creates project with all fields (e2e)', async () => {
const res = await fetch('http://localhost:3000/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: 'AllFieldsE2E',
total_budget: '2500.50',
start_date: '2025-03-01',
end_date: '2025-09-30',
currency: 'EUR',
}),
});
expect(res.status).toBe(201);
const json = await res.json();
const res = await handler(postEvent({
name: 'AllFieldsE2E',
total_budget: '2500.50',
start_date: '2025-03-01',
end_date: '2025-09-30',
currency: 'EUR',
}));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.name).toBe('AllFieldsE2E');
expect(json.total_budget).toBeDefined();
expect(json.start_date).toContain('2025-03-01');
Expand All @@ -48,29 +58,51 @@ describe('POST /projects (e2e)', () => {
});

test('400 when name missing', async () => {
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ total_budget: 10 }),
});
expect(res.status).toBe(400);
const res = await handler(postEvent({ total_budget: 10 }));
expect(res.statusCode).toBe(400);
});

test('400 when total_budget invalid', async () => {
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'X', total_budget: 'abc' }),
});
expect(res.status).toBe(400);
const res = await handler(postEvent({ name: 'X', total_budget: 'abc' }));
expect(res.statusCode).toBe(400);
});

test('201 with only required name (optional omitted)', async () => {
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Minimal' }),
});
expect(res.status).toBe(201);
const res = await handler(postEvent({ name: 'Minimal' }));
expect(res.statusCode).toBe(201);
});
});

describe('GET /projects/{id}/expenditures (e2e)', () => {
test('get expenditures for project 1 test 🌞', async () => {
const res = await handler(getExpendituresEvent('1'));
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(Array.isArray(body)).toBe(true);
});

test('expenditures 404 test 🌞', async () => {
const res = await handler(getExpendituresEvent('99999'));
expect(res.statusCode).toBe(404);
const body = JSON.parse(res.body);
expect(body.message).toBe('Project not found');
});

test('expenditures ordered by spent_on test 🌞', async () => {
const res = await handler(getExpendituresEvent('1'));
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);

if (body.length > 1) {
for (let i = 0; i < body.length - 1; i++) {
const current = new Date(body[i].spent_on);
const next = new Date(body[i + 1].spent_on);
expect(current >= next).toBe(true);
}
}
});
});

afterAll(async () => {
await db.destroy();
});
34 changes: 34 additions & 0 deletions apps/backend/lambdas/projects/test/projects.unit.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { handler } from '../handler';
import db from '../db';

function event(body: unknown) {
return {
Expand Down Expand Up @@ -86,3 +87,36 @@ test('400: currency empty or too long', async () => {
const tooLong = await handler(event({ name: 'X', currency: 'ABCDEFGHIJK' })); // 11 chars
expect(tooLong.statusCode).toBe(400);
});


function getExpendituresEvent(id: string) {
return {
rawPath: `/projects/${id}/expenditures`,
requestContext: { http: { method: 'GET' } },
} as any;
}

test('200: returns expenditures array', async () => {
const res = await handler(getExpendituresEvent('1'));
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(Array.isArray(json)).toBe(true);
});

test('404: project not found', async () => {
const res = await handler(getExpendituresEvent('99999'));
expect(res.statusCode).toBe(404);
const json = JSON.parse(res.body);
expect(json.message).toBe('Project not found');
});

test('500: invalid id causes error', async () => {
const res = await handler(getExpendituresEvent('invalid'));
expect(res.statusCode).toBe(500);
const json = JSON.parse(res.body);
expect(json.message).toContain('Failed to fetch expenditures');
});

afterAll(async () => {
await db.destroy();
});