From ab2dd7e7045ca652889a9049e1d5ee107dffea41 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 2 Dec 2025 09:25:11 -0500 Subject: [PATCH 1/5] feat: implement update task functionality with validation and logging - Add update-task handler to process PUT requests for updating tasks in DynamoDB. - Introduce UpdateTaskDto schema for validating incoming request bodies. - Implement unit tests for update-task handler covering various scenarios including validation errors and successful updates. - Create task-service function to handle the update logic, including dynamic update expressions for optional fields. - Add logging for tracking request flow and errors. - Ensure that the task is returned without the primary key in the response. --- docs/InfrastructureGuide.md | 60 ++- infrastructure/README.md | 36 +- infrastructure/stacks/lambda-stack.test.ts | 49 ++ infrastructure/stacks/lambda-stack.ts | 46 ++ src/handlers/update-task.test.ts | 536 +++++++++++++++++++++ src/handlers/update-task.ts | 94 ++++ src/models/update-task-dto.test.ts | 247 ++++++++++ src/models/update-task-dto.ts | 18 + src/services/task-service.test.ts | 282 +++++++++++ src/services/task-service.ts | 81 +++- 10 files changed, 1446 insertions(+), 3 deletions(-) create mode 100644 src/handlers/update-task.test.ts create mode 100644 src/handlers/update-task.ts create mode 100644 src/models/update-task-dto.test.ts create mode 100644 src/models/update-task-dto.ts diff --git a/docs/InfrastructureGuide.md b/docs/InfrastructureGuide.md index 5ec0edb..b7132d1 100644 --- a/docs/InfrastructureGuide.md +++ b/docs/InfrastructureGuide.md @@ -493,6 +493,40 @@ const tableName = process.env.TASK_TABLE_NAME; - **DynamoDB**: Write access (PutItem) to the Task table - **CloudWatch Logs**: Write access to its log group +#### Update Task Function + +**Resource Type**: AWS Lambda Function + +**Configuration**: + +- **Function Name**: `{app-name}-update-task-{env}` +- **Runtime**: Node.js 24.x +- **Handler**: `handler` (bundled with esbuild) +- **Memory**: 256 MB +- **Timeout**: 10 seconds +- **Log Format**: JSON (structured logging) +- **Bundling**: Automatic TypeScript compilation with esbuild +- **Environment Variables**: + - `TASKS_TABLE`: DynamoDB table name + - `ENABLE_LOGGING`: Logging enabled flag (from `CDK_APP_ENABLE_LOGGING`) + - `LOG_LEVEL`: Minimum log level (from `CDK_APP_LOGGING_LEVEL`) + - `LOG_FORMAT`: Log output format (from `CDK_APP_LOGGING_FORMAT`) + +**CloudWatch Logs**: + +- **Log Group**: `/aws/lambda/{app-name}-update-task-{env}` +- **Log Retention**: + - `prd`: 30 days + - Other environments: 7 days +- **Removal Policy**: + - `prd`: `RETAIN` (logs preserved on stack deletion) + - Other environments: `DESTROY` (logs deleted on stack deletion) + +**IAM Permissions**: + +- **DynamoDB**: Read-write access (GetItem, UpdateItem) to the Task table +- **CloudWatch Logs**: Write access to its log group + #### Lambda Starter API **Resource Type**: API Gateway REST API @@ -526,8 +560,31 @@ const tableName = process.env.TASK_TABLE_NAME; - **POST /tasks**: Create a new task - Integration: Lambda proxy integration with Create Task Function - - Request Body: JSON object with task properties (title, description, status) + - Request Body: JSON object with task properties + - `title` (required): string, max 100 characters + - `detail` (optional): string, max 1000 characters + - `dueAt` (optional): ISO8601 timestamp + - `isComplete` (optional): boolean, defaults to false - Response: JSON object with created task including ID and timestamps + - Success Status: 201 Created + - Error Responses: + - 400 Bad Request: Invalid request body or validation error + - 500 Internal Server Error: Failed to create task + +- **PUT /tasks/{taskId}**: Update an existing task + - Integration: Lambda proxy integration with Update Task Function + - Path Parameter: `taskId` - The unique identifier of the task + - Request Body: JSON object with task properties + - `title` (required): string, max 100 characters + - `detail` (optional): string, max 1000 characters - omit to remove from task + - `dueAt` (optional): ISO8601 timestamp - omit to remove from task + - `isComplete` (required): boolean + - Response: JSON object with updated task + - Success Status: 200 OK + - Error Responses: + - 400 Bad Request: Invalid request body or validation error + - 404 Not Found: Task ID does not exist + - 500 Internal Server Error: Failed to update task **Outputs**: @@ -536,6 +593,7 @@ const tableName = process.env.TASK_TABLE_NAME; - `ListTasksFunctionArn`: The List Tasks Lambda function ARN - `GetTaskFunctionArn`: The Get Task Lambda function ARN - `CreateTaskFunctionArn`: The Create Task Lambda function ARN +- `UpdateTaskFunctionArn`: The Update Task Lambda function ARN **Logging Configuration**: diff --git a/infrastructure/README.md b/infrastructure/README.md index 0721849..7c6dab5 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -273,6 +273,32 @@ npm run cdk destroy --all - Runtime: Node.js 24.x - Memory: 256 MB - Timeout: 10 seconds + - Handler: Retrieves all tasks from DynamoDB + - IAM Permissions: Read access to Task table (Scan) + +- **Get Task Function** (`get-task-{env}`) + - Runtime: Node.js 24.x + - Memory: 256 MB + - Timeout: 10 seconds + - Handler: Retrieves a single task by ID + - IAM Permissions: Read access to Task table (GetItem) + +- **Create Task Function** (`create-task-{env}`) + - Runtime: Node.js 24.x + - Memory: 256 MB + - Timeout: 10 seconds + - Handler: Creates a new task in DynamoDB + - IAM Permissions: Write access to Task table (PutItem) + +- **Update Task Function** (`update-task-{env}`) + - Runtime: Node.js 24.x + - Memory: 256 MB + - Timeout: 10 seconds + - Handler: Updates an existing task in DynamoDB + - IAM Permissions: Read-write access to Task table (GetItem, UpdateItem) + +**Common Lambda Configuration:** + - Log Format: JSON (structured logging) - Log Retention: - `prd`: 30 days @@ -283,6 +309,11 @@ npm run cdk destroy --all - **Lambda Starter API** (`lambda-starter-api-{env}`) - Type: REST API + - Endpoints: + - `GET /tasks` - List all tasks + - `GET /tasks/{taskId}` - Get a specific task + - `POST /tasks` - Create a new task + - `PUT /tasks/{taskId}` - Update an existing task - CORS: Enabled with preflight OPTIONS support - Throttling: Rate and burst limits configured - Stage: `{env}` (e.g., `dev`, `prd`) @@ -291,7 +322,10 @@ npm run cdk destroy --all - `ApiUrl`: The API Gateway endpoint URL - `ApiId`: The API Gateway ID -- `ListTasksFunctionArn`: The Lambda function ARN +- `ListTasksFunctionArn`: The List Tasks Lambda function ARN +- `GetTaskFunctionArn`: The Get Task Lambda function ARN +- `CreateTaskFunctionArn`: The Create Task Lambda function ARN +- `UpdateTaskFunctionArn`: The Update Task Lambda function ARN **Logging Configuration:** diff --git a/infrastructure/stacks/lambda-stack.test.ts b/infrastructure/stacks/lambda-stack.test.ts index e675dd7..6507f58 100644 --- a/infrastructure/stacks/lambda-stack.test.ts +++ b/infrastructure/stacks/lambda-stack.test.ts @@ -77,6 +77,16 @@ describe('LambdaStack', () => { }); }); + it('should create an update task Lambda function', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + FunctionName: 'lambda-starter-update-task-dev', + Runtime: 'nodejs24.x', + Handler: 'handler', + Timeout: 10, + MemorySize: 256, + }); + }); + it('should configure Lambda environment variables', () => { template.hasResourceProperties('AWS::Lambda::Function', { Environment: { @@ -122,6 +132,12 @@ describe('LambdaStack', () => { }); }); + it('should create a PUT method on /tasks/{taskId}', () => { + template.hasResourceProperties('AWS::ApiGateway::Method', { + HttpMethod: 'PUT', + }); + }); + it('should integrate API Gateway with Lambda', () => { template.hasResourceProperties('AWS::ApiGateway::Method', { Integration: { @@ -225,6 +241,39 @@ describe('LambdaStack', () => { }, }); }); + + it('should export update task function ARN', () => { + template.hasOutput('UpdateTaskFunctionArn', { + Export: { + Name: 'lambda-starter-update-task-function-arn-dev', + }, + }); + }); + + it('should grant Lambda read-write access to DynamoDB for update function', () => { + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith([ + 'dynamodb:BatchGetItem', + 'dynamodb:GetRecords', + 'dynamodb:GetShardIterator', + 'dynamodb:Query', + 'dynamodb:GetItem', + 'dynamodb:Scan', + 'dynamodb:ConditionCheckItem', + 'dynamodb:BatchWriteItem', + 'dynamodb:PutItem', + 'dynamodb:UpdateItem', + 'dynamodb:DeleteItem', + 'dynamodb:DescribeTable', + ]), + }), + ]), + }, + }); + }); }); describe('prd environment', () => { diff --git a/infrastructure/stacks/lambda-stack.ts b/infrastructure/stacks/lambda-stack.ts index bab1374..d2bbb50 100644 --- a/infrastructure/stacks/lambda-stack.ts +++ b/infrastructure/stacks/lambda-stack.ts @@ -66,6 +66,11 @@ export class LambdaStack extends cdk.Stack { */ public readonly createTaskFunction: NodejsFunction; + /** + * The update task Lambda function. + */ + public readonly updateTaskFunction: NodejsFunction; + constructor(scope: Construct, id: string, props: LambdaStackProps) { super(scope, id, props); @@ -162,6 +167,37 @@ export class LambdaStack extends cdk.Stack { // Grant the Lambda function write access to the DynamoDB table props.taskTable.grantWriteData(this.createTaskFunction); + // Create the update task Lambda function + this.updateTaskFunction = new NodejsFunction(this, 'UpdateTaskFunction', { + functionName: `${props.appName}-update-task-${props.envName}`, + runtime: lambda.Runtime.NODEJS_24_X, + handler: 'handler', + entry: path.join(__dirname, '../../src/handlers/update-task.ts'), + environment: { + TASKS_TABLE: props.taskTable.tableName, + ENABLE_LOGGING: props.enableLogging.toString(), + LOG_LEVEL: props.loggingLevel, + LOG_FORMAT: props.loggingFormat, + }, + timeout: cdk.Duration.seconds(10), + memorySize: 256, + bundling: { + minify: true, + sourceMap: true, + }, + loggingFormat: lambda.LoggingFormat.JSON, + applicationLogLevelV2: lambda.ApplicationLogLevel.INFO, + systemLogLevelV2: lambda.SystemLogLevel.INFO, + logGroup: new logs.LogGroup(this, 'UpdateTaskFunctionLogGroup', { + logGroupName: `/aws/lambda/${props.appName}-update-task-${props.envName}`, + retention: props.envName === 'prd' ? logs.RetentionDays.ONE_MONTH : logs.RetentionDays.ONE_WEEK, + removalPolicy: props.envName === 'prd' ? cdk.RemovalPolicy.RETAIN : cdk.RemovalPolicy.DESTROY, + }), + }); + + // Grant the Lambda function read and write access to the DynamoDB table + props.taskTable.grantReadWriteData(this.updateTaskFunction); + // Create API Gateway REST API this.api = new apigateway.RestApi(this, 'LambdaStarterApi', { restApiName: `${props.appName}-api-${props.envName}`, @@ -193,6 +229,9 @@ export class LambdaStack extends cdk.Stack { // Add GET method to /tasks/{taskId} taskResource.addMethod('GET', new apigateway.LambdaIntegration(this.getTaskFunction)); + // Add PUT method to /tasks/{taskId} + taskResource.addMethod('PUT', new apigateway.LambdaIntegration(this.updateTaskFunction)); + // Output the API URL new cdk.CfnOutput(this, 'ApiUrl', { value: this.api.url, @@ -227,5 +266,12 @@ export class LambdaStack extends cdk.Stack { description: 'ARN of the create task Lambda function', exportName: `${props.appName}-create-task-function-arn-${props.envName}`, }); + + // Output the update task function ARN + new cdk.CfnOutput(this, 'UpdateTaskFunctionArn', { + value: this.updateTaskFunction.functionArn, + description: 'ARN of the update task Lambda function', + exportName: `${props.appName}-update-task-function-arn-${props.envName}`, + }); } } diff --git a/src/handlers/update-task.test.ts b/src/handlers/update-task.test.ts new file mode 100644 index 0000000..12ee4d2 --- /dev/null +++ b/src/handlers/update-task.test.ts @@ -0,0 +1,536 @@ +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; + +import { Task } from '../models/task'; + +// Mock dependencies BEFORE importing handler +const mockUpdateTask = jest.fn(); +const mockLoggerInfo = jest.fn(); +const mockLoggerWarn = jest.fn(); +const mockLoggerError = jest.fn(); + +jest.mock('../utils/config', () => ({ + config: { + TASKS_TABLE: 'test-tasks-table', + AWS_REGION: 'us-east-1', + ENABLE_LOGGING: true, + LOG_LEVEL: 'info', + CORS_ALLOW_ORIGIN: '*', + }, +})); + +jest.mock('../services/task-service', () => ({ + updateTask: mockUpdateTask, +})); + +jest.mock('../utils/logger', () => ({ + logger: { + info: mockLoggerInfo, + warn: mockLoggerWarn, + error: mockLoggerError, + }, +})); + +describe('update-task handler', () => { + let handler: typeof import('./update-task').handler; + + beforeEach(() => { + jest.clearAllMocks(); + + // Import handler after mocks are set up + handler = require('./update-task').handler; + }); + + const createMockEvent = (overrides?: Partial): APIGatewayProxyEvent => { + return { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'PUT', + isBase64Encoded: false, + path: '/tasks/123e4567-e89b-12d3-a456-426614174000', + pathParameters: { taskId: '123e4567-e89b-12d3-a456-426614174000' }, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: { + accountId: '123456789012', + apiId: 'test-api-id', + authorizer: null, + protocol: 'HTTP/1.1', + httpMethod: 'PUT', + identity: { + accessKey: null, + accountId: null, + apiKey: null, + apiKeyId: null, + caller: null, + clientCert: null, + cognitoAuthenticationProvider: null, + cognitoAuthenticationType: null, + cognitoIdentityId: null, + cognitoIdentityPoolId: null, + principalOrgId: null, + sourceIp: '127.0.0.1', + user: null, + userAgent: 'test-agent', + userArn: null, + }, + path: '/tasks/123e4567-e89b-12d3-a456-426614174000', + stage: 'test', + requestId: 'test-request-id', + requestTimeEpoch: Date.now(), + resourceId: 'test-resource-id', + resourcePath: '/tasks/{taskId}', + }, + resource: '/tasks/{taskId}', + ...overrides, + }; + }; + + const createMockContext = (): Context => { + return { + callbackWaitsForEmptyEventLoop: false, + functionName: 'test-function', + functionVersion: '1', + invokedFunctionArn: 'arn:aws:lambda:us-east-1:123456789012:function:test-function', + memoryLimitInMB: '128', + awsRequestId: 'test-aws-request-id', + logGroupName: '/aws/lambda/test-function', + logStreamName: '2025/12/01/[$LATEST]test', + getRemainingTimeInMillis: () => 30000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + }; + + describe('handler', () => { + it('should update a task and return 200 with updated task', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const requestBody = { + title: 'Updated Task', + detail: 'Updated detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: true, + }; + + const mockTask: Task = { + id: taskId, + title: 'Updated Task', + detail: 'Updated detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: true, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockUpdateTask.mockResolvedValue(mockTask); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual(mockTask); + expect(mockUpdateTask).toHaveBeenCalledTimes(1); + expect(mockUpdateTask).toHaveBeenCalledWith(taskId, requestBody); + expect(mockLoggerInfo).toHaveBeenCalledWith('[UpdateTask] > handler', expect.any(Object)); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[UpdateTask] < handler - successfully updated task', + expect.any(Object), + ); + }); + + it('should update a task with only required fields', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const requestBody = { + title: 'Updated Task', + isComplete: false, + }; + + const mockTask: Task = { + id: taskId, + title: 'Updated Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockUpdateTask.mockResolvedValue(mockTask); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual(mockTask); + expect(mockUpdateTask).toHaveBeenCalledTimes(1); + }); + + it('should return 400 when taskId is missing', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + isComplete: false, + }; + + const event = createMockEvent({ + body: JSON.stringify(requestBody), + pathParameters: null, + }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body)).toEqual({ message: 'Task ID is required' }); + expect(mockUpdateTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith('[UpdateTask] < handler - missing taskId', expect.any(Object)); + }); + + it('should return 400 when request body is missing', async () => { + // Arrange + const event = createMockEvent({ body: null }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body)).toEqual({ message: 'Request body is required' }); + expect(mockUpdateTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith('[UpdateTask] < handler - missing request body', expect.any(Object)); + }); + + it('should return 400 when request body is not valid JSON', async () => { + // Arrange + const event = createMockEvent({ body: 'invalid-json' }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + expect(JSON.parse(result.body)).toEqual({ message: 'Invalid JSON in request body' }); + expect(mockUpdateTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith( + '[UpdateTask] < handler - invalid JSON in request body', + expect.any(Object), + ); + }); + + it('should return 404 when task is not found', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + isComplete: false, + }; + + mockUpdateTask.mockResolvedValue(null); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(404); + expect(mockUpdateTask).toHaveBeenCalledTimes(1); + expect(mockLoggerInfo).toHaveBeenCalledWith('[UpdateTask] < handler - task not found', expect.any(Object)); + }); + + it('should return 400 when title is missing', async () => { + // Arrange + const requestBody = { + detail: 'Updated detail', + isComplete: false, + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(responseBody.message).toContain('title'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith('[UpdateTask] < handler - validation error', expect.any(Object)); + }); + + it('should return 400 when title is empty', async () => { + // Arrange + const requestBody = { + title: '', + isComplete: false, + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(responseBody.message).toContain('Title is required'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when title exceeds 100 characters', async () => { + // Arrange + const requestBody = { + title: 'a'.repeat(101), + isComplete: false, + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(responseBody.message).toContain('Title must not exceed 100 characters'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when detail exceeds 1000 characters', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + detail: 'a'.repeat(1001), + isComplete: false, + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(responseBody.message).toContain('Detail must not exceed 1000 characters'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when dueAt is not a valid ISO8601 timestamp', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + dueAt: 'not-a-date', + isComplete: false, + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(responseBody.message).toContain('Due date must be a valid ISO8601 timestamp'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when isComplete is missing', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + detail: 'Updated detail', + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(responseBody.message).toContain('isComplete'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when isComplete is not a boolean', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + isComplete: 'true', // string instead of boolean + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when extra fields are provided', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + isComplete: false, + extraField: 'not allowed', + }; + + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(400); + const responseBody = JSON.parse(result.body); + expect(responseBody.message).toContain('Validation failed'); + expect(mockUpdateTask).not.toHaveBeenCalled(); + }); + + it('should return 500 when update fails with unexpected error', async () => { + // Arrange + const requestBody = { + title: 'Updated Task', + isComplete: false, + }; + + const mockError = new Error('DynamoDB error'); + mockUpdateTask.mockRejectedValue(mockError); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(500); + expect(JSON.parse(result.body)).toEqual({ message: 'Failed to update task' }); + expect(mockUpdateTask).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalledWith( + '[UpdateTask] < handler - failed to update task', + mockError, + expect.any(Object), + ); + }); + + it('should handle updating task with detail', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const requestBody = { + title: 'Updated Task', + detail: 'New detail', + isComplete: false, + }; + + const mockTask: Task = { + id: taskId, + title: 'Updated Task', + detail: 'New detail', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockUpdateTask.mockResolvedValue(mockTask); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).detail).toBe('New detail'); + }); + + it('should handle updating task with dueAt', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const requestBody = { + title: 'Updated Task', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + }; + + const mockTask: Task = { + id: taskId, + title: 'Updated Task', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockUpdateTask.mockResolvedValue(mockTask); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).dueAt).toBe('2025-12-31T23:59:59.000Z'); + }); + + it('should handle marking task as complete', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const requestBody = { + title: 'Updated Task', + isComplete: true, + }; + + const mockTask: Task = { + id: taskId, + title: 'Updated Task', + isComplete: true, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockUpdateTask.mockResolvedValue(mockTask); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body).isComplete).toBe(true); + }); + }); +}); diff --git a/src/handlers/update-task.ts b/src/handlers/update-task.ts new file mode 100644 index 0000000..539cfc0 --- /dev/null +++ b/src/handlers/update-task.ts @@ -0,0 +1,94 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { lambdaRequestTracker } from 'pino-lambda'; +import { ZodError } from 'zod'; + +import { UpdateTaskDtoSchema } from '../models/update-task-dto.js'; +import { updateTask } from '../services/task-service.js'; +import { badRequest, internalServerError, notFound, ok } from '../utils/apigateway-response.js'; +import { logger } from '../utils/logger.js'; + +/** + * Lambda request tracker middleware for logging. + * @see https://www.npmjs.com/package/pino-lambda#best-practices + */ +const withRequestTracking = lambdaRequestTracker(); + +/** + * Lambda handler for updating an existing task + * Handles PUT requests from API Gateway to update a task in DynamoDB + * + * @param event - API Gateway proxy event + * @returns API Gateway proxy result with updated task or error message + */ +export const handler = async (event: APIGatewayProxyEvent, context: Context): Promise => { + withRequestTracking(event, context); + logger.info('[UpdateTask] > handler', { + requestId: event.requestContext.requestId, + event, + }); + + try { + // Extract taskId from path parameters + const taskId = event.pathParameters?.taskId; + if (!taskId) { + logger.warn('[UpdateTask] < handler - missing taskId', { + requestId: event.requestContext.requestId, + }); + return badRequest('Task ID is required'); + } + + // Parse and validate request body + if (!event.body) { + logger.warn('[UpdateTask] < handler - missing request body', { + requestId: event.requestContext.requestId, + }); + return badRequest('Request body is required'); + } + + let requestBody: unknown; + try { + requestBody = JSON.parse(event.body); + } catch (_error) { + logger.warn('[UpdateTask] < handler - invalid JSON in request body', { + requestId: event.requestContext.requestId, + }); + return badRequest('Invalid JSON in request body'); + } + + // Validate request body against schema + const validatedDto = UpdateTaskDtoSchema.parse(requestBody); + + // Update the task + const task = await updateTask(taskId, validatedDto); + + if (!task) { + logger.info('[UpdateTask] < handler - task not found', { + taskId, + requestId: event.requestContext.requestId, + }); + return notFound(); + } + + logger.info('[UpdateTask] < handler - successfully updated task', { + id: task.id, + requestId: event.requestContext.requestId, + }); + + return ok(task); + } catch (error) { + if (error instanceof ZodError) { + const errorMessages = error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join(', '); + logger.warn('[UpdateTask] < handler - validation error', { + errors: error.issues, + requestId: event.requestContext.requestId, + }); + return badRequest(`Validation failed: ${errorMessages}`); + } + + logger.error('[UpdateTask] < handler - failed to update task', error as Error, { + requestId: event.requestContext.requestId, + }); + + return internalServerError('Failed to update task'); + } +}; diff --git a/src/models/update-task-dto.test.ts b/src/models/update-task-dto.test.ts new file mode 100644 index 0000000..372a6aa --- /dev/null +++ b/src/models/update-task-dto.test.ts @@ -0,0 +1,247 @@ +import { UpdateTaskDtoSchema } from './update-task-dto'; + +describe('update-task-dto', () => { + describe('UpdateTaskDtoSchema', () => { + it('should validate a valid update task DTO with all fields', () => { + // Arrange + const validDto = { + title: 'Test Task', + detail: 'Test detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validDto); + } + }); + + it('should validate a valid update task DTO with only required fields', () => { + // Arrange + const validDto = { + title: 'Test Task', + isComplete: true, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validDto); + } + }); + + it('should validate a valid update task DTO with detail but no dueAt', () => { + // Arrange + const validDto = { + title: 'Test Task', + detail: 'Test detail', + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validDto); + } + }); + + it('should validate a valid update task DTO with dueAt but no detail', () => { + // Arrange + const validDto = { + title: 'Test Task', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: true, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validDto); + } + }); + + it('should reject when title is missing', () => { + // Arrange + const invalidDto = { + detail: 'Test detail', + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0].path).toEqual(['title']); + } + }); + + it('should reject when title is empty string', () => { + // Arrange + const invalidDto = { + title: '', + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('Title is required'); + } + }); + + it('should reject when title exceeds 100 characters', () => { + // Arrange + const invalidDto = { + title: 'a'.repeat(101), + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('Title must not exceed 100 characters'); + } + }); + + it('should reject when detail exceeds 1000 characters', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + detail: 'a'.repeat(1001), + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('Detail must not exceed 1000 characters'); + } + }); + + it('should reject when dueAt is not a valid ISO8601 timestamp', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + dueAt: 'not-a-date', + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('Due date must be a valid ISO8601 timestamp'); + } + }); + + it('should reject when isComplete is missing', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + detail: 'Test detail', + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues).toHaveLength(1); + expect(result.error.issues[0].path).toEqual(['isComplete']); + } + }); + + it('should reject when isComplete is not a boolean', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + isComplete: 'true', // string instead of boolean + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['isComplete']); + } + }); + + it('should reject when extra fields are provided', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + isComplete: false, + extraField: 'not allowed', + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].code).toBe('unrecognized_keys'); + } + }); + + it('should accept 100 character title', () => { + // Arrange + const validDto = { + title: 'a'.repeat(100), + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + }); + + it('should accept 1000 character detail', () => { + // Arrange + const validDto = { + title: 'Test Task', + detail: 'a'.repeat(1000), + isComplete: false, + }; + + // Act + const result = UpdateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + }); + }); +}); diff --git a/src/models/update-task-dto.ts b/src/models/update-task-dto.ts new file mode 100644 index 0000000..1cab1ea --- /dev/null +++ b/src/models/update-task-dto.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; + +/** + * Zod schema for validating update task request body + */ +export const UpdateTaskDtoSchema = z + .object({ + title: z.string().min(1, 'Title is required').max(100, 'Title must not exceed 100 characters'), + detail: z.string().max(1000, 'Detail must not exceed 1000 characters').optional(), + dueAt: z.string().datetime({ message: 'Due date must be a valid ISO8601 timestamp' }).optional(), + isComplete: z.boolean(), + }) + .strict(); + +/** + * Type representing the validated update task DTO + */ +export type UpdateTaskDto = z.infer; diff --git a/src/services/task-service.test.ts b/src/services/task-service.test.ts index 2b3484a..14e0fcf 100644 --- a/src/services/task-service.test.ts +++ b/src/services/task-service.test.ts @@ -1,4 +1,5 @@ import { CreateTaskDto } from '../models/create-task-dto'; +import { UpdateTaskDto } from '../models/update-task-dto'; import { TaskItem } from '../models/task'; // Mock dependencies @@ -36,6 +37,7 @@ describe('task-service', () => { let listTasks: typeof import('./task-service').listTasks; let getTask: typeof import('./task-service').getTask; let createTask: typeof import('./task-service').createTask; + let updateTask: typeof import('./task-service').updateTask; beforeEach(() => { // Clear all mocks @@ -46,6 +48,7 @@ describe('task-service', () => { listTasks = taskService.listTasks; getTask = taskService.getTask; createTask = taskService.createTask; + updateTask = taskService.updateTask; }); describe('listTasks', () => { @@ -444,4 +447,283 @@ describe('task-service', () => { expect(result).not.toHaveProperty('pk'); }); }); + + describe('updateTask', () => { + beforeEach(() => { + // Mock Date.now to return a fixed timestamp + jest.spyOn(global, 'Date').mockImplementation(() => { + return { + toISOString: () => '2025-12-01T10:00:00.000Z', + } as Date; + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should update a task with all fields', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + detail: 'Updated detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: true, + }; + + const updatedTaskItem: TaskItem = { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: taskId, + title: 'Updated Task', + detail: 'Updated detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: true, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockSend.mockResolvedValue({ + Attributes: updatedTaskItem, + }); + + // Act + const result = await updateTask(taskId, updateTaskDto); + + // Assert + expect(result).not.toBeNull(); + expect(result?.title).toBe('Updated Task'); + expect(result?.detail).toBe('Updated detail'); + expect(result?.dueAt).toBe('2025-12-31T23:59:59.000Z'); + expect(result?.isComplete).toBe(true); + expect(result?.updatedAt).toBe('2025-12-01T10:00:00.000Z'); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLoggerInfo).toHaveBeenCalledWith('[TaskService] > updateTask', { + tableName: 'test-tasks-table', + id: taskId, + }); + }); + + it('should update a task with only required fields', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + isComplete: false, + }; + + const updatedTaskItem: TaskItem = { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: taskId, + title: 'Updated Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockSend.mockResolvedValue({ + Attributes: updatedTaskItem, + }); + + // Act + const result = await updateTask(taskId, updateTaskDto); + + // Assert + expect(result).not.toBeNull(); + expect(result?.title).toBe('Updated Task'); + expect(result?.detail).toBeUndefined(); + expect(result?.dueAt).toBeUndefined(); + expect(result?.isComplete).toBe(false); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should return null when task is not found', async () => { + // Arrange + const taskId = 'non-existent-id'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + isComplete: false, + }; + + const mockError = new Error('The conditional request failed'); + mockError.name = 'ConditionalCheckFailedException'; + mockSend.mockRejectedValue(mockError); + + // Act + const result = await updateTask(taskId, updateTaskDto); + + // Assert + expect(result).toBeNull(); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLoggerInfo).toHaveBeenCalledWith('[TaskService] < updateTask - task not found', { + id: taskId, + }); + }); + + it('should update task and set updatedAt to current time', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + isComplete: false, + }; + + const updatedTaskItem: TaskItem = { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: taskId, + title: 'Updated Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockSend.mockResolvedValue({ + Attributes: updatedTaskItem, + }); + + // Act + const result = await updateTask(taskId, updateTaskDto); + + // Assert + expect(result?.updatedAt).toBe('2025-12-01T10:00:00.000Z'); + }); + + it('should handle DynamoDB errors and rethrow them', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + isComplete: false, + }; + + const mockError = new Error('DynamoDB error'); + mockSend.mockRejectedValue(mockError); + + // Act & Assert + await expect(updateTask(taskId, updateTaskDto)).rejects.toThrow('DynamoDB error'); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalled(); + }); + + it('should not include pk field in returned task', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + isComplete: false, + }; + + const updatedTaskItem: TaskItem = { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: taskId, + title: 'Updated Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockSend.mockResolvedValue({ + Attributes: updatedTaskItem, + }); + + // Act + const result = await updateTask(taskId, updateTaskDto); + + // Assert + expect(result).not.toHaveProperty('pk'); + }); + + it('should use UpdateCommand with correct parameters', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + detail: 'Updated detail', + isComplete: false, + }; + + mockSend.mockResolvedValue({ + Attributes: { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: taskId, + title: 'Updated Task', + detail: 'Updated detail', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }, + }); + + // Act + await updateTask(taskId, updateTaskDto); + + // Assert + expect(mockSend).toHaveBeenCalledTimes(1); + const command = mockSend.mock.calls[0][0]; + expect(command.input.TableName).toBe('test-tasks-table'); + expect(command.input.Key).toEqual({ pk: 'TASK#123e4567-e89b-12d3-a456-426614174000' }); + expect(command.input.ConditionExpression).toBe('attribute_exists(pk)'); + expect(command.input.ReturnValues).toBe('ALL_NEW'); + }); + + it('should add detail to update expression when detail is provided', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + detail: 'New detail', + isComplete: false, + }; + + mockSend.mockResolvedValue({ + Attributes: { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: taskId, + title: 'Updated Task', + detail: 'New detail', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }, + }); + + // Act + await updateTask(taskId, updateTaskDto); + + // Assert + const command = mockSend.mock.calls[0][0]; + expect(command.input.UpdateExpression).toContain('detail = :detail'); + expect(command.input.ExpressionAttributeValues[':detail']).toBe('New detail'); + }); + + it('should add dueAt to update expression when dueAt is provided', async () => { + // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; + const updateTaskDto: UpdateTaskDto = { + title: 'Updated Task', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + }; + + mockSend.mockResolvedValue({ + Attributes: { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: taskId, + title: 'Updated Task', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }, + }); + + // Act + await updateTask(taskId, updateTaskDto); + + // Assert + const command = mockSend.mock.calls[0][0]; + expect(command.input.UpdateExpression).toContain('dueAt = :dueAt'); + expect(command.input.ExpressionAttributeValues[':dueAt']).toBe('2025-12-31T23:59:59.000Z'); + }); + }); }); diff --git a/src/services/task-service.ts b/src/services/task-service.ts index ce32cab..776196a 100644 --- a/src/services/task-service.ts +++ b/src/services/task-service.ts @@ -1,7 +1,8 @@ import { randomUUID } from 'crypto'; -import { GetCommand, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, ScanCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; import { CreateTaskDto } from '../models/create-task-dto.js'; +import { UpdateTaskDto } from '../models/update-task-dto.js'; import { Task, TaskItem, TaskKeys, toTask } from '../models/task.js'; import { config } from '../utils/config.js'; import { dynamoDocClient } from '../utils/dynamodb-client.js'; @@ -125,3 +126,81 @@ export const createTask = async (createTaskDto: CreateTaskDto): Promise => throw error; } }; + +/** + * Updates an existing task in the DynamoDB table + * @param id - The unique identifier of the task to update + * @param updateTaskDto - The data to update the task with + * @returns Promise that resolves to the updated Task object if found, or null if not found + * @throws Error if the DynamoDB update operation fails + */ +export const updateTask = async (id: string, updateTaskDto: UpdateTaskDto): Promise => { + logger.info('[TaskService] > updateTask', { tableName: config.TASKS_TABLE, id }); + + try { + const now = new Date().toISOString(); + + // Build update expression dynamically + let updateExpression = 'SET title = :title, isComplete = :isComplete, updatedAt = :updatedAt'; + const expressionAttributeValues: Record = { + ':title': updateTaskDto.title, + ':isComplete': updateTaskDto.isComplete, + ':updatedAt': now, + }; + + // Handle optional detail field + if (updateTaskDto.detail !== undefined) { + updateExpression += ', detail = :detail'; + expressionAttributeValues[':detail'] = updateTaskDto.detail; + } else { + // Remove detail if not present in request + updateExpression += ' REMOVE detail'; + } + + // Handle optional dueAt field + if (updateTaskDto.dueAt !== undefined) { + updateExpression += ', dueAt = :dueAt'; + expressionAttributeValues[':dueAt'] = updateTaskDto.dueAt; + } else { + // Remove dueAt if not present in request + updateExpression += ' REMOVE dueAt'; + } + + const command = new UpdateCommand({ + TableName: config.TASKS_TABLE, + Key: { + pk: TaskKeys.pk(id), + }, + UpdateExpression: updateExpression, + ExpressionAttributeValues: expressionAttributeValues, + ConditionExpression: 'attribute_exists(pk)', + ReturnValues: 'ALL_NEW', + }); + logger.debug('[TaskService] updateTask - UpdateCommand', { command }); + + const response = await dynamoDocClient.send(command); + + if (!response.Attributes) { + logger.info('[TaskService] < updateTask - task not found', { id }); + return null; + } + + const task = toTask(response.Attributes as TaskItem); + + logger.info('[TaskService] < updateTask - successfully updated task', { id }); + + return task; + } catch (error) { + // Check if the error is a conditional check failure (task not found) + if (error instanceof Error && error.name === 'ConditionalCheckFailedException') { + logger.info('[TaskService] < updateTask - task not found', { id }); + return null; + } + + logger.error('[TaskService] < updateTask - failed to update task in DynamoDB', error as Error, { + tableName: config.TASKS_TABLE, + id, + }); + throw error; + } +}; From 1d5064716c42dc70c229f0635044a1cc6f2c7d43 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 2 Dec 2025 09:25:26 -0500 Subject: [PATCH 2/5] fix: format log configuration section in README for clarity --- infrastructure/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/infrastructure/README.md b/infrastructure/README.md index 7c6dab5..99947a6 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -299,13 +299,13 @@ npm run cdk destroy --all **Common Lambda Configuration:** - - Log Format: JSON (structured logging) - - Log Retention: - - `prd`: 30 days - - Other environments: 7 days - - Log Removal Policy: - - `prd`: `RETAIN` (logs preserved on stack deletion) - - Other environments: `DESTROY` (logs deleted on stack deletion) +- Log Format: JSON (structured logging) +- Log Retention: + - `prd`: 30 days + - Other environments: 7 days +- Log Removal Policy: + - `prd`: `RETAIN` (logs preserved on stack deletion) + - Other environments: `DESTROY` (logs deleted on stack deletion) - **Lambda Starter API** (`lambda-starter-api-{env}`) - Type: REST API From c6dfe9fb4a86539692d4b4200811bfe7af91d0e0 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 2 Dec 2025 09:51:12 -0500 Subject: [PATCH 3/5] feat: enhance updateTask to support dynamic update and removal of optional fields --- src/services/task-service.ts | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/services/task-service.ts b/src/services/task-service.ts index 776196a..798196d 100644 --- a/src/services/task-service.ts +++ b/src/services/task-service.ts @@ -141,7 +141,8 @@ export const updateTask = async (id: string, updateTaskDto: UpdateTaskDto): Prom const now = new Date().toISOString(); // Build update expression dynamically - let updateExpression = 'SET title = :title, isComplete = :isComplete, updatedAt = :updatedAt'; + const setExpressions: string[] = ['title = :title', 'isComplete = :isComplete', 'updatedAt = :updatedAt']; + const removeExpressions: string[] = []; const expressionAttributeValues: Record = { ':title': updateTaskDto.title, ':isComplete': updateTaskDto.isComplete, @@ -150,22 +151,32 @@ export const updateTask = async (id: string, updateTaskDto: UpdateTaskDto): Prom // Handle optional detail field if (updateTaskDto.detail !== undefined) { - updateExpression += ', detail = :detail'; + setExpressions.push('detail = :detail'); expressionAttributeValues[':detail'] = updateTaskDto.detail; } else { // Remove detail if not present in request - updateExpression += ' REMOVE detail'; + removeExpressions.push('detail'); } // Handle optional dueAt field if (updateTaskDto.dueAt !== undefined) { - updateExpression += ', dueAt = :dueAt'; + setExpressions.push('dueAt = :dueAt'); expressionAttributeValues[':dueAt'] = updateTaskDto.dueAt; } else { // Remove dueAt if not present in request - updateExpression += ' REMOVE dueAt'; + removeExpressions.push('dueAt'); } + // Construct the update expression with proper syntax + const updateExpressionParts: string[] = []; + if (setExpressions.length > 0) { + updateExpressionParts.push(`SET ${setExpressions.join(', ')}`); + } + if (removeExpressions.length > 0) { + updateExpressionParts.push(`REMOVE ${removeExpressions.join(', ')}`); + } + const updateExpression = updateExpressionParts.join(' '); + const command = new UpdateCommand({ TableName: config.TASKS_TABLE, Key: { From 2870d47d6edd2e95eac1d54682c9fb8bd907c0ab Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 2 Dec 2025 10:22:06 -0500 Subject: [PATCH 4/5] feat: allow extra fields like createdAt and updatedAt in task DTOs --- src/handlers/update-task.test.ts | 27 +++++++++++++++++++++------ src/models/create-task-dto.test.ts | 19 ++++++++++++------- src/models/create-task-dto.ts | 14 ++++++-------- src/models/update-task-dto.test.ts | 18 +++++++++++------- src/models/update-task-dto.ts | 14 ++++++-------- 5 files changed, 56 insertions(+), 36 deletions(-) diff --git a/src/handlers/update-task.test.ts b/src/handlers/update-task.test.ts index 12ee4d2..a31f923 100644 --- a/src/handlers/update-task.test.ts +++ b/src/handlers/update-task.test.ts @@ -398,14 +398,26 @@ describe('update-task handler', () => { expect(mockUpdateTask).not.toHaveBeenCalled(); }); - it('should return 400 when extra fields are provided', async () => { + it('should allow extra fields like createdAt and updatedAt', async () => { // Arrange + const taskId = '123e4567-e89b-12d3-a456-426614174000'; const requestBody = { title: 'Updated Task', isComplete: false, - extraField: 'not allowed', + createdAt: '2025-01-01T10:00:00.000Z', + updatedAt: '2025-12-02T10:00:00.000Z', + id: '123e4567-e89b-12d3-a456-426614174000', + }; + + const mockTask: Task = { + id: taskId, + title: 'Updated Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', }; + mockUpdateTask.mockResolvedValue(mockTask); const event = createMockEvent({ body: JSON.stringify(requestBody) }); const context = createMockContext(); @@ -413,10 +425,13 @@ describe('update-task handler', () => { const result = await handler(event, context); // Assert - expect(result.statusCode).toBe(400); - const responseBody = JSON.parse(result.body); - expect(responseBody.message).toContain('Validation failed'); - expect(mockUpdateTask).not.toHaveBeenCalled(); + expect(result.statusCode).toBe(200); + expect(mockUpdateTask).toHaveBeenCalledTimes(1); + // Verify only the validated DTO fields are passed to the service + expect(mockUpdateTask).toHaveBeenCalledWith(taskId, { + title: 'Updated Task', + isComplete: false, + }); }); it('should return 500 when update fails with unexpected error', async () => { diff --git a/src/models/create-task-dto.test.ts b/src/models/create-task-dto.test.ts index d19062e..8ef4b22 100644 --- a/src/models/create-task-dto.test.ts +++ b/src/models/create-task-dto.test.ts @@ -231,20 +231,25 @@ describe('create-task-dto', () => { } }); - it('should reject when extra fields are provided', () => { + it('should allow extra fields like createdAt and updatedAt', () => { // Arrange - const invalidDto = { + const validDto = { title: 'Test Task', - extraField: 'should not be here', + isComplete: false, + createdAt: '2025-01-01T10:00:00.000Z', + updatedAt: '2025-12-02T10:00:00.000Z', + id: '123e4567-e89b-12d3-a456-426614174000', }; // Act - const result = CreateTaskDtoSchema.safeParse(invalidDto); + const result = CreateTaskDtoSchema.safeParse(validDto); // Assert - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].code).toBe('unrecognized_keys'); + expect(result.success).toBe(true); + if (result.success) { + // Validated DTO only contains the fields we defined in the schema + expect(result.data.title).toBe('Test Task'); + expect(result.data.isComplete).toBe(false); } }); }); diff --git a/src/models/create-task-dto.ts b/src/models/create-task-dto.ts index 53b0cc0..6ea4685 100644 --- a/src/models/create-task-dto.ts +++ b/src/models/create-task-dto.ts @@ -3,14 +3,12 @@ import { z } from 'zod'; /** * Zod schema for validating create task request body */ -export const CreateTaskDtoSchema = z - .object({ - title: z.string().min(1, 'Title is required').max(100, 'Title must not exceed 100 characters'), - detail: z.string().max(1000, 'Detail must not exceed 1000 characters').optional(), - dueAt: z.string().datetime({ message: 'Due date must be a valid ISO8601 timestamp' }).optional(), - isComplete: z.boolean().default(false), - }) - .strict(); +export const CreateTaskDtoSchema = z.object({ + title: z.string().min(1, 'Title is required').max(100, 'Title must not exceed 100 characters'), + detail: z.string().max(1000, 'Detail must not exceed 1000 characters').optional(), + dueAt: z.iso.datetime({ message: 'Due date must be a valid ISO8601 timestamp' }).optional(), + isComplete: z.boolean().default(false), +}); /** * Type representing the validated create task DTO diff --git a/src/models/update-task-dto.test.ts b/src/models/update-task-dto.test.ts index 372a6aa..8842e51 100644 --- a/src/models/update-task-dto.test.ts +++ b/src/models/update-task-dto.test.ts @@ -197,21 +197,25 @@ describe('update-task-dto', () => { } }); - it('should reject when extra fields are provided', () => { + it('should allow extra fields like createdAt and updatedAt', () => { // Arrange - const invalidDto = { + const validDto = { title: 'Test Task', isComplete: false, - extraField: 'not allowed', + createdAt: '2025-01-01T10:00:00.000Z', + updatedAt: '2025-12-02T10:00:00.000Z', + id: '123e4567-e89b-12d3-a456-426614174000', }; // Act - const result = UpdateTaskDtoSchema.safeParse(invalidDto); + const result = UpdateTaskDtoSchema.safeParse(validDto); // Assert - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].code).toBe('unrecognized_keys'); + expect(result.success).toBe(true); + if (result.success) { + // Validated DTO only contains the fields we defined in the schema + expect(result.data.title).toBe('Test Task'); + expect(result.data.isComplete).toBe(false); } }); diff --git a/src/models/update-task-dto.ts b/src/models/update-task-dto.ts index 1cab1ea..214234f 100644 --- a/src/models/update-task-dto.ts +++ b/src/models/update-task-dto.ts @@ -3,14 +3,12 @@ import { z } from 'zod'; /** * Zod schema for validating update task request body */ -export const UpdateTaskDtoSchema = z - .object({ - title: z.string().min(1, 'Title is required').max(100, 'Title must not exceed 100 characters'), - detail: z.string().max(1000, 'Detail must not exceed 1000 characters').optional(), - dueAt: z.string().datetime({ message: 'Due date must be a valid ISO8601 timestamp' }).optional(), - isComplete: z.boolean(), - }) - .strict(); +export const UpdateTaskDtoSchema = z.object({ + title: z.string().min(1, 'Title is required').max(100, 'Title must not exceed 100 characters'), + detail: z.string().max(1000, 'Detail must not exceed 1000 characters').optional(), + dueAt: z.iso.datetime({ message: 'Due date must be a valid ISO8601 timestamp' }).optional(), + isComplete: z.boolean(), +}); /** * Type representing the validated update task DTO From f7a18340504bd262387ee959c47d4cc8bc6c874a Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 2 Dec 2025 10:24:39 -0500 Subject: [PATCH 5/5] test: update create-task-dto test to validate exclusion of extra fields --- src/models/create-task-dto.test.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/models/create-task-dto.test.ts b/src/models/create-task-dto.test.ts index 8ef4b22..560af14 100644 --- a/src/models/create-task-dto.test.ts +++ b/src/models/create-task-dto.test.ts @@ -233,7 +233,7 @@ describe('create-task-dto', () => { it('should allow extra fields like createdAt and updatedAt', () => { // Arrange - const validDto = { + const invalidDto = { title: 'Test Task', isComplete: false, createdAt: '2025-01-01T10:00:00.000Z', @@ -242,7 +242,7 @@ describe('create-task-dto', () => { }; // Act - const result = CreateTaskDtoSchema.safeParse(validDto); + const result = CreateTaskDtoSchema.safeParse(invalidDto); // Assert expect(result.success).toBe(true); @@ -250,6 +250,9 @@ describe('create-task-dto', () => { // Validated DTO only contains the fields we defined in the schema expect(result.data.title).toBe('Test Task'); expect(result.data.isComplete).toBe(false); + expect(result.data).not.toHaveProperty('createdAt'); + expect(result.data).not.toHaveProperty('updatedAt'); + expect(result.data).not.toHaveProperty('id'); } }); });