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
43 changes: 41 additions & 2 deletions apps/backend/lambdas/projects/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
// >>> ROUTES-START (do not remove this marker)
// CLI-generated routes will be inserted here
// GET /projects/{id}/members
if (normalizedPath.startsWith('/projects/') && normalizedPath.split('/').length === 4 && method === 'GET') {
if (normalizedPath.startsWith('/projects/') && normalizedPath.split('/').length === 4 && normalizedPath.endsWith('/members') && method === 'GET') {
const id = normalizedPath.split('/')[2];
if (!id) return json(400, { message: 'id is required' });
const users = await db
Expand Down Expand Up @@ -117,7 +117,46 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(500, { message: 'Failed to create project' });
}
}
// <<< ROUTES-END

// 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' });
}
}

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
console.error('Lambda error:', err);
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 @@ -49,3 +49,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
14 changes: 14 additions & 0 deletions apps/backend/lambdas/projects/test/crud.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,20 @@ test("update project test 🌞", async () => {
expect(body.project_id).toBe(1);
expect(body.name).toContain("Project 1 Updated");
expect(Number(body.total_budget)).toBe(Number(2000.00));
expect(body.description).toBeDefined();
expect(body.description).not.toBeNull();
expect(typeof body.description).toBe('string');
});

test("update project with new description test 🌞", async () => {
const newDesc = "Updated project description";
let res = await fetch("http://localhost:3000/projects/1", {
method: "PUT",
body: JSON.stringify({ name: "Project 1", description: newDesc }),
});
expect(res.status).toBe(200);
let body = await res.json();
expect(body.description).toBe(newDesc);
});

test("project put 404 test 🌞", async () => {
Expand Down
160 changes: 90 additions & 70 deletions apps/backend/lambdas/projects/test/projects.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,56 @@
// 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',
description: 'End-to-end project description',
}),
});
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',
description: 'End-to-end project description',
}));
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 @@ -50,66 +60,76 @@ 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 json = await res.json();
const res = await handler(postEvent({ name: 'Minimal' }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.description).toBe(''); // description defaults to empty string
});

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

test('400: description exceeds 1000 characters', async () => {
const longDesc = 'a'.repeat(1001);
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'LongDesc', description: longDesc }),
});
expect(res.status).toBe(400);
const json = await res.json();
const res = await handler(postEvent({ name: 'LongDesc', description: longDesc }));
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.message).toContain('1000');
});

test('201: creates project with exactly 1000 character description', async () => {
const desc1000 = 'a'.repeat(1000);
const res = await fetch(`${base}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'MaxDesc', description: desc1000 }),
});
expect(res.status).toBe(201);
const json = await res.json();
const res = await handler(postEvent({ name: 'MaxDesc', description: desc1000 }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.description).toBe(desc1000);
});
});

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();
});
33 changes: 33 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 @@ -120,3 +121,35 @@ test('400: description exceeds 1000 characters', async () => {
const json = JSON.parse(res.body);
expect(json.message).toContain('1000');
});

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();
});
Loading