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
13 changes: 6 additions & 7 deletions apps/backend/lambdas/projects/db.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Kysely, PostgresDialect } from 'kysely'
import { Pool } from 'pg'
import type { DB } from './db-types'

import { Kysely, PostgresDialect } from 'kysely';
import { Pool } from 'pg';
import type { DB } from './db-types';

const db = new Kysely<DB>({
dialect: new PostgresDialect({
Expand All @@ -11,9 +10,9 @@ const db = new Kysely<DB>({
user: process.env.DB_USER ?? 'branch_dev',
password: process.env.DB_PASSWORD ?? 'password',
database: process.env.DB_NAME ?? 'branch_db',
ssl: false,
ssl: false,
}),
}),
})
});

export default db
export default db;
50 changes: 48 additions & 2 deletions apps/backend/lambdas/projects/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda';

import db from './db';
import { ProjectValidationUtils } from './validation-utils';


export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
try {
Expand All @@ -25,7 +26,52 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
return json(200, projects);
}

// <<< ROUTES-END
// POST /projects
if ((normalizedPath === '' || normalizedPath === '/' || normalizedPath === '/projects') && method === 'POST') {
let body: Record<string, unknown>;
try {
body = event.body ? JSON.parse(event.body) as Record<string, unknown> : {};
} catch (e) {
return json(400, { message: 'Invalid JSON in request body' });
}

const nameResult = ProjectValidationUtils.validateName(body.name);
if (!nameResult.isValid) {
return json(400, { message: nameResult.error });
}

const values: any = { name: nameResult.value };

const parsedBudget = ProjectValidationUtils.parseNumericToFixed(body.total_budget);
if (parsedBudget === 'INVALID') return json(400, { message: "'total_budget' must be a number" });
if (parsedBudget !== null) values.total_budget = parsedBudget;

const startDateResult = ProjectValidationUtils.validateDate(body.start_date, 'start_date');
if (!startDateResult.isValid) return json(400, { message: startDateResult.error });
if (startDateResult.value !== null) values.start_date = startDateResult.value;

const endDateResult = ProjectValidationUtils.validateDate(body.end_date, 'end_date');
if (!endDateResult.isValid) return json(400, { message: endDateResult.error });
if (endDateResult.value !== null) values.end_date = endDateResult.value;

const currencyResult = ProjectValidationUtils.validateCurrency(body.currency);
if (!currencyResult.isValid) return json(400, { message: currencyResult.error });
if (currencyResult.value !== null) values.currency = currencyResult.value;

try {
const inserted = await db
.insertInto('branch.projects')
.values(values)
.returning(['project_id','name','total_budget','currency','start_date','end_date','created_at'])
.executeTakeFirst();

return json(201, inserted);
} catch (e) {
console.error('DB insert failed', e);
return json(500, { message: 'Failed to create project' });
}
}
// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/lambdas/projects/jest.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/*.test.ts'],
globals: { 'ts-jest': { isolatedModules: true } },
};
45 changes: 4 additions & 41 deletions apps/backend/lambdas/projects/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,53 +20,16 @@ paths:
type: boolean

/projects:
get:
summary: GET /projects
responses:
'200':
description: OK

/projects/{id}:
get:
summary: GET /projects/{id}
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: OK

/projects/{id}:
put:
summary: PUT /projects/{id}
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
'200':
description: OK

/projects/{id}:
put:
summary: PUT /projects/{id}
parameters:
- in: path
name: id
required: true
schema:
type: string
post:
summary: POST /projects
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
Expand Down
18 changes: 17 additions & 1 deletion apps/backend/lambdas/projects/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion apps/backend/lambdas/projects/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@
},
"devDependencies": {
"@types/aws-lambda": "^8.10.131",
"@types/jest": "^30.0.0",
"@types/node": "^20.11.30",
"@types/pg": "^8.15.6",
"js-yaml": "^4.1.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.5"
"typescript": "^5.4.5",
"start-server-and-test": "^2.1.1"
},
"dependencies": {
Expand Down
76 changes: 76 additions & 0 deletions apps/backend/lambdas/projects/test/projects.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// E2E tests require the dev server running at http://localhost:3000/projects

const base = 'http://localhost:3000/projects';

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();
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();
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();
expect(json.name).toBe('AllFieldsE2E');
expect(json.total_budget).toBeDefined();
expect(json.start_date).toBe('2025-03-01T05:00:00.000Z');
expect(json.end_date).toBe('2025-09-30T04:00:00.000Z');
expect(json.currency).toBe('EUR');
});

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

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

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);
});
});
88 changes: 88 additions & 0 deletions apps/backend/lambdas/projects/test/projects.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { handler } from '../handler';

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

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

test('201: creates project with number budget', async () => {
const res = await handler(event({ name: 'Proj Number', total_budget: 1000 }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.name).toBe('Proj Number');
expect(json.project_id).toBeDefined();
expect(json.total_budget).toBeDefined();
});

test('201: creates project with numeric string budget', async () => {
const res = await handler(event({ name: 'Proj String', total_budget: '2500.50' }));
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.name).toBe('Proj String');
});

test('201: creates minimal project with only name', async () => {
const res = await handler(event({ name: 'Minimal' }));
expect(res.statusCode).toBe(201);
});

test('201: creates project with all fields', async () => {
const res = await handler({
rawPath: '/',
requestContext: { http: { method: 'POST' } },
body: JSON.stringify({
name: 'AllFieldsUnit',
total_budget: 12345.67,
start_date: '2025-01-01',
end_date: '2025-12-31',
currency: 'USD',
}),
} as any);
expect(res.statusCode).toBe(201);
const json = JSON.parse(res.body);
expect(json.name).toBe('AllFieldsUnit');
expect(json.total_budget).toBeDefined();
expect(json.start_date).toBe('2025-01-01T05:00:00.000Z');
expect(json.end_date).toBe('2025-12-31T05:00:00.000Z');
expect(json.currency).toBe('USD');
});

// Validation errors (400)
test('400: missing name', async () => {
const res = await handler(event({ total_budget: 10 }));
expect(res.statusCode).toBe(400);
});

test('400: invalid total_budget non-numeric', async () => {
const res = await handler(event({ name: 'X', total_budget: 'abc' }));
expect(res.statusCode).toBe(400);
});

test('400: invalid start_date format', async () => {
const res = await handler(event({ name: 'X', start_date: '2025/01/01' }));
expect(res.statusCode).toBe(400);
});

test('400: invalid end_date format', async () => {
const res = await handler(event({ name: 'X', end_date: '01-01-2025' }));
expect(res.statusCode).toBe(400);
});

test('400: currency empty or too long', async () => {
const empty = await handler(event({ name: 'X', currency: '' }));
expect(empty.statusCode).toBe(400);

const tooLong = await handler(event({ name: 'X', currency: 'ABCDEFGHIJK' })); // 11 chars
expect(tooLong.statusCode).toBe(400);
});
Loading