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
1 change: 1 addition & 0 deletions apps/backend/lambdas/expenditures/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Lambda for tracking project expenditures.
| Method | Path | Description |
|--------|------|-------------|
| GET | /health | Health check |
| GET | /expenditures | |
| POST | /expenditures | |

## Setup
Expand Down
65 changes: 63 additions & 2 deletions apps/backend/lambdas/expenditures/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,68 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {

// >>> ROUTES-START (do not remove this marker)
// CLI-generated routes will be inserted here


// GET /expenditures
if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'GET') {
const authContext = await authenticateRequest(event);
if (!authContext.isAuthenticated) {
return json(401, { message: 'Authentication required' });
}

const queryParams = event.queryStringParameters || {};
const pageStr = queryParams.page as string | undefined;
const limitStr = queryParams.limit as string | undefined;
const projectIdStr = queryParams.projectId as string | undefined;

if (pageStr !== undefined) {
if (!/^\d+$/.test(pageStr) || parseInt(pageStr, 10) < 1) {
return json(400, { message: 'page must be a positive integer' });
}
}

if (limitStr !== undefined) {
if (!/^\d+$/.test(limitStr) || parseInt(limitStr, 10) < 1) {
return json(400, { message: 'limit must be a positive integer' });
}
}

if (projectIdStr !== undefined) {
if (!/^\d+$/.test(projectIdStr) || parseInt(projectIdStr, 10) < 1) {
return json(400, { message: 'projectId must be a positive integer' });
}
}

const page = pageStr ? parseInt(pageStr, 10) : null;
const limit = limitStr ? parseInt(limitStr, 10) : null;
const projectId = projectIdStr ? parseInt(projectIdStr, 10) : null;

if (page && limit) {
const offset = (page - 1) * limit;

const totalCount = projectId !== null
? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).select(db.fn.count('expenditure_id').as('count')).executeTakeFirst()
: await db.selectFrom('branch.expenditures').select(db.fn.count('expenditure_id').as('count')).executeTakeFirst();

const totalItems = Number(totalCount?.count || 0);
const totalPages = Math.ceil(totalItems / limit);

const expenditures = projectId !== null
? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute()
: await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').limit(limit).offset(offset).execute();

return json(200, {
data: expenditures,
pagination: { page, limit, totalItems, totalPages },
});
}

const expenditures = projectId !== null
? await db.selectFrom('branch.expenditures').where('project_id', '=', projectId).selectAll().orderBy('spent_on', 'desc').execute()
: await db.selectFrom('branch.expenditures').selectAll().orderBy('spent_on', 'desc').execute();

return json(200, { data: expenditures });
}

// POST /expenditures
if ((normalizedPath === '/expenditures' || normalizedPath === '' || normalizedPath === '/') && method === 'POST') {
// Authenticate the request
Expand Down Expand Up @@ -96,7 +157,7 @@ export const handler = async (event: any): Promise<APIGatewayProxyResult> => {
},
});
}
// <<< ROUTES-END
// <<< ROUTES-END

return json(404, { message: 'Not Found', path: normalizedPath, method });
} catch (err) {
Expand Down
48 changes: 48 additions & 0 deletions apps/backend/lambdas/expenditures/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,54 @@ paths:
type: boolean

/expenditures:
get:
summary: GET /expenditures — paginated list
parameters:
- in: query
name: page
schema:
type: integer
minimum: 1
description: Page number (requires limit)
- in: query
name: limit
schema:
type: integer
minimum: 1
description: Items per page (requires page)
- in: query
name: projectId
schema:
type: integer
minimum: 1
description: Filter expenditures by project
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
pagination:
type: object
properties:
page:
type: integer
limit:
type: integer
totalItems:
type: integer
totalPages:
type: integer
'400':
description: Invalid pagination params
'401':
description: Unauthorized
post:
summary: POST /expenditures
requestBody:
Expand Down
136 changes: 134 additions & 2 deletions apps/backend/lambdas/expenditures/test/expenditures.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,12 @@ function postEvent(body: Record<string, unknown>) {
};
}

function getEvent(path: string) {
function getEvent(path: string, queryStringParameters?: Record<string, string>) {
return {
rawPath: path,
requestContext: { http: { method: 'GET' } },
headers: {},
headers: { Authorization: 'Bearer fake-token' },
queryStringParameters: queryStringParameters ?? {},
};
}

Expand Down Expand Up @@ -120,6 +121,13 @@ describe('Expenditures integration tests', () => {
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.body).message).toBe('Authentication required');
});

test('401: GET /expenditures rejects unauthenticated request', async () => {
mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false });

const res = await handler(getEvent('/'));
expect(res.statusCode).toBe(401);
});
});

describe('Authorization', () => {
Expand Down Expand Up @@ -208,4 +216,128 @@ describe('Expenditures integration tests', () => {
expect(JSON.parse(res.body).message).toBe('Project not found');
});
});

describe('GET /expenditures — list and pagination', () => {
test('200: returns all expenditures with data envelope', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/'));
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBe(3);
expect(body.pagination).toBeUndefined();
});

test('200: ordered newest first by spent_on', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/'));
const body = JSON.parse(res.body);
const dates = body.data.map((e: any) => new Date(e.spent_on).getTime());
expect(dates[0]).toBeGreaterThanOrEqual(dates[1]);
expect(dates[1]).toBeGreaterThanOrEqual(dates[2]);
});

test('200: paginated response with page and limit', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '1', limit: '1' }));
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.length).toBe(1);
expect(body.pagination).toBeDefined();
expect(body.pagination.page).toBe(1);
expect(body.pagination.limit).toBe(1);
expect(body.pagination.totalItems).toBe(3);
expect(body.pagination.totalPages).toBe(3);
});

test('200: page 2 returns second item', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '2', limit: '1' }));
const body = JSON.parse(res.body);
expect(res.statusCode).toBe(200);
expect(body.data.length).toBe(1);
expect(body.pagination.page).toBe(2);
});

test('200: limit larger than total returns all items', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '1', limit: '100' }));
const body = JSON.parse(res.body);
expect(res.statusCode).toBe(200);
expect(body.data.length).toBe(3);
expect(body.pagination.totalItems).toBe(3);
expect(body.pagination.totalPages).toBe(1);
});

test('200: only page provided returns all without pagination', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '1' }));
const body = JSON.parse(res.body);
expect(res.statusCode).toBe(200);
expect(body.pagination).toBeUndefined();
expect(body.data.length).toBe(3);
});

test('200: only limit provided returns all without pagination', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { limit: '2' }));
const body = JSON.parse(res.body);
expect(res.statusCode).toBe(200);
expect(body.pagination).toBeUndefined();
expect(body.data.length).toBe(3);
});

test('200: filter by projectId returns only matching expenditures', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { projectId: '1' }));
const body = JSON.parse(res.body);
expect(res.statusCode).toBe(200);
expect(body.data.every((e: any) => e.project_id === 1)).toBe(true);
});

test('200: projectId filter with pagination', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { projectId: '1', page: '1', limit: '10' }));
const body = JSON.parse(res.body);
expect(res.statusCode).toBe(200);
expect(body.pagination.totalItems).toBe(1);
expect(body.data.every((e: any) => e.project_id === 1)).toBe(true);
});

test('400: page=0 returns 400', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '0', limit: '10' }));
expect(res.statusCode).toBe(400);
});

test('400: negative page returns 400', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '-1', limit: '10' }));
expect(res.statusCode).toBe(400);
});

test('400: non-integer page returns 400', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: 'abc', limit: '10' }));
expect(res.statusCode).toBe(400);
});

test('400: limit=0 returns 400', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '1', limit: '0' }));
expect(res.statusCode).toBe(400);
});

test('400: decimal limit returns 400', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { page: '1', limit: '1.5' }));
expect(res.statusCode).toBe(400);
});

test('400: invalid projectId returns 400', async () => {
mockAuthenticateRequest.mockResolvedValue(adminUser);
const res = await handler(getEvent('/', { projectId: 'abc' }));
expect(res.statusCode).toBe(400);
});
});
});
Loading
Loading