diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index 49ccc05..a76e6b3 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -86,6 +86,46 @@ export const handler = async (event: any): Promise => { 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 }); diff --git a/apps/backend/lambdas/projects/openapi.yaml b/apps/backend/lambdas/projects/openapi.yaml index 7b45fbb..9da10aa 100644 --- a/apps/backend/lambdas/projects/openapi.yaml +++ b/apps/backend/lambdas/projects/openapi.yaml @@ -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 diff --git a/apps/backend/lambdas/projects/test/projects.e2e.test.ts b/apps/backend/lambdas/projects/test/projects.e2e.test.ts index cacc763..f98a4e4 100644 --- a/apps/backend/lambdas/projects/test/projects.e2e.test.ts +++ b/apps/backend/lambdas/projects/test/projects.e2e.test.ts @@ -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'); @@ -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(); }); \ No newline at end of file diff --git a/apps/backend/lambdas/projects/test/projects.unit.test.ts b/apps/backend/lambdas/projects/test/projects.unit.test.ts index 04ac428..35294d0 100644 --- a/apps/backend/lambdas/projects/test/projects.unit.test.ts +++ b/apps/backend/lambdas/projects/test/projects.unit.test.ts @@ -1,4 +1,5 @@ import { handler } from '../handler'; +import db from '../db'; function event(body: unknown) { return { @@ -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(); +}); \ No newline at end of file