diff --git a/apps/backend/lambdas/projects/db.ts b/apps/backend/lambdas/projects/db.ts index 38f9b85..6a2fa8a 100644 --- a/apps/backend/lambdas/projects/db.ts +++ b/apps/backend/lambdas/projects/db.ts @@ -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({ dialect: new PostgresDialect({ @@ -11,9 +10,9 @@ const db = new Kysely({ 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; diff --git a/apps/backend/lambdas/projects/handler.ts b/apps/backend/lambdas/projects/handler.ts index b5927e3..0c24d41 100644 --- a/apps/backend/lambdas/projects/handler.ts +++ b/apps/backend/lambdas/projects/handler.ts @@ -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 => { try { @@ -25,7 +26,52 @@ export const handler = async (event: any): Promise => { return json(200, projects); } - // <<< ROUTES-END + // POST /projects + if ((normalizedPath === '' || normalizedPath === '/' || normalizedPath === '/projects') && method === 'POST') { + let body: Record; + try { + body = event.body ? JSON.parse(event.body) as Record : {}; + } 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) { diff --git a/apps/backend/lambdas/projects/jest.config.js b/apps/backend/lambdas/projects/jest.config.js new file mode 100644 index 0000000..6bb00ee --- /dev/null +++ b/apps/backend/lambdas/projects/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + testMatch: ['**/*.test.ts'], + globals: { 'ts-jest': { isolatedModules: true } }, + }; \ No newline at end of file diff --git a/apps/backend/lambdas/projects/openapi.yaml b/apps/backend/lambdas/projects/openapi.yaml index 049ad23..7b45fbb 100644 --- a/apps/backend/lambdas/projects/openapi.yaml +++ b/apps/backend/lambdas/projects/openapi.yaml @@ -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 diff --git a/apps/backend/lambdas/projects/package-lock.json b/apps/backend/lambdas/projects/package-lock.json index d7e72f2..9b360c3 100644 --- a/apps/backend/lambdas/projects/package-lock.json +++ b/apps/backend/lambdas/projects/package-lock.json @@ -14,11 +14,13 @@ }, "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-jest": "^29.4.5", "ts-node": "^10.9.2", - "typescript": "^5.4.5" + "typescript": "^5.9.3" } }, "node_modules/@babel/code-frame": { @@ -4194,6 +4196,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/apps/backend/lambdas/projects/package.json b/apps/backend/lambdas/projects/package.json index 603abb0..41a4804 100644 --- a/apps/backend/lambdas/projects/package.json +++ b/apps/backend/lambdas/projects/package.json @@ -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": { diff --git a/apps/backend/lambdas/projects/test/projects.e2e.test.ts b/apps/backend/lambdas/projects/test/projects.e2e.test.ts new file mode 100644 index 0000000..2362075 --- /dev/null +++ b/apps/backend/lambdas/projects/test/projects.e2e.test.ts @@ -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); + }); +}); \ 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 new file mode 100644 index 0000000..b94bbc1 --- /dev/null +++ b/apps/backend/lambdas/projects/test/projects.unit.test.ts @@ -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); +}); diff --git a/apps/backend/lambdas/projects/validation-utils.ts b/apps/backend/lambdas/projects/validation-utils.ts new file mode 100644 index 0000000..b2124ec --- /dev/null +++ b/apps/backend/lambdas/projects/validation-utils.ts @@ -0,0 +1,75 @@ +// Result type for input validation operations +export type ValidationResult = { + isValid: boolean; + value?: T; + error?: string; +}; + +// Utility class for validating project-related input fields +export class ProjectValidationUtils { + // Parses numeric input (number or string), converts to fixed 2-decimal string for database storage + static parseNumericToFixed(input: unknown): string | null | 'INVALID' { + if (input === undefined || input === null || input === '') return null; + let numeric: number; + if (typeof input === 'number') { + numeric = input; + } else if (typeof input === 'string') { + const trimmed = input.trim(); + if (trimmed === '') return null; + numeric = Number(trimmed); + } else { + numeric = NaN; + } + if (!Number.isFinite(numeric)) return 'INVALID'; + return numeric.toFixed(2); + } + + // Validates if a string matches YYYY-MM-DD date format + static isValidDate(s: string): boolean { + return /^\d{4}-\d{2}-\d{2}$/.test(s); + } + + // Validates that name is a non-empty string (required field) + static validateName(input: unknown): ValidationResult { + if (typeof input !== 'string') { + return { isValid: false, error: "'name' is required" }; + } + const trimmed = input.trim(); + if (!trimmed) { + return { isValid: false, error: "'name' is required" }; + } + return { isValid: true, value: trimmed }; + } + + // Validates optional date field - if provided, must be YYYY-MM-DD format + static validateDate(input: unknown, fieldName: string): ValidationResult { + if (input === undefined || input === null || input === '') { + return { isValid: true, value: null }; + } + if (typeof input !== 'string') { + return { isValid: true, value: null }; + } + if (!this.isValidDate(input)) { + return { isValid: false, error: `'${fieldName}' must be YYYY-MM-DD` }; + } + return { isValid: true, value: input }; + } + + // Validates optional currency field - if provided, must be 1-10 characters + static validateCurrency(input: unknown): ValidationResult { + if (input === undefined || input === null) { + return { isValid: true, value: null }; + } + if (typeof input !== 'string') { + return { isValid: true, value: null }; + } + const c = input.trim(); + if (c.length === 0) { + return { isValid: false, error: "'currency' must be 1-10 chars" }; + } + if (c.length > 10) { + return { isValid: false, error: "'currency' must be 1-10 chars" }; + } + return { isValid: true, value: c }; + } +}