From 3af0129c4829c8216228c3aa6b0a5f52794341a5 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 1 Dec 2025 14:58:59 -0500 Subject: [PATCH 1/5] Implement create task functionality with validation and tests --- infrastructure/stacks/lambda-stack.test.ts | 42 +++ infrastructure/stacks/lambda-stack.ts | 46 +++ src/handlers/create-task.test.ts | 374 +++++++++++++++++++++ src/handlers/create-task.ts | 77 +++++ src/models/create-task-dto.test.ts | 251 ++++++++++++++ src/models/create-task-dto.ts | 18 + src/services/task-service.test.ts | 198 ++++++++++- src/services/task-service.ts | 52 ++- 8 files changed, 1055 insertions(+), 3 deletions(-) create mode 100644 src/handlers/create-task.test.ts create mode 100644 src/handlers/create-task.ts create mode 100644 src/models/create-task-dto.test.ts create mode 100644 src/models/create-task-dto.ts diff --git a/infrastructure/stacks/lambda-stack.test.ts b/infrastructure/stacks/lambda-stack.test.ts index 3153721..55f114a 100644 --- a/infrastructure/stacks/lambda-stack.test.ts +++ b/infrastructure/stacks/lambda-stack.test.ts @@ -57,6 +57,16 @@ describe('LambdaStack', () => { }); }); + it('should create a create task Lambda function', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + FunctionName: 'lambda-starter-create-task-dev', + Runtime: 'nodejs24.x', + Handler: 'handler', + Timeout: 10, + MemorySize: 256, + }); + }); + it('should configure Lambda environment variables', () => { template.hasResourceProperties('AWS::Lambda::Function', { Environment: { @@ -90,6 +100,12 @@ describe('LambdaStack', () => { }); }); + it('should create a POST method on /tasks', () => { + template.hasResourceProperties('AWS::ApiGateway::Method', { + HttpMethod: 'POST', + }); + }); + it('should integrate API Gateway with Lambda', () => { template.hasResourceProperties('AWS::ApiGateway::Method', { Integration: { @@ -136,6 +152,24 @@ describe('LambdaStack', () => { }); }); + it('should grant Lambda write access to DynamoDB', () => { + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: [ + 'dynamodb:BatchWriteItem', + 'dynamodb:PutItem', + 'dynamodb:UpdateItem', + 'dynamodb:DeleteItem', + 'dynamodb:DescribeTable', + ], + }), + ]), + }, + }); + }); + it('should export API URL', () => { template.hasOutput('ApiUrl', { Export: { @@ -159,6 +193,14 @@ describe('LambdaStack', () => { }, }); }); + + it('should export create task function ARN', () => { + template.hasOutput('CreateTaskFunctionArn', { + Export: { + Name: 'dev-create-task-function-arn', + }, + }); + }); }); describe('prd environment', () => { diff --git a/infrastructure/stacks/lambda-stack.ts b/infrastructure/stacks/lambda-stack.ts index 51f6f01..7b04e0b 100644 --- a/infrastructure/stacks/lambda-stack.ts +++ b/infrastructure/stacks/lambda-stack.ts @@ -56,6 +56,11 @@ export class LambdaStack extends cdk.Stack { */ public readonly listTasksFunction: NodejsFunction; + /** + * The create task Lambda function. + */ + public readonly createTaskFunction: NodejsFunction; + constructor(scope: Construct, id: string, props: LambdaStackProps) { super(scope, id, props); @@ -90,6 +95,37 @@ export class LambdaStack extends cdk.Stack { // Grant the Lambda function read access to the DynamoDB table props.taskTable.grantReadData(this.listTasksFunction); + // Create the create task Lambda function + this.createTaskFunction = new NodejsFunction(this, 'CreateTaskFunction', { + functionName: `${props.appName}-create-task-${props.envName}`, + runtime: lambda.Runtime.NODEJS_24_X, + handler: 'handler', + entry: path.join(__dirname, '../../src/handlers/create-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, 'CreateTaskFunctionLogGroup', { + logGroupName: `/aws/lambda/${props.appName}-create-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 write access to the DynamoDB table + props.taskTable.grantWriteData(this.createTaskFunction); + // Create API Gateway REST API this.api = new apigateway.RestApi(this, 'LambdaStarterApi', { restApiName: `${props.appName}-api-${props.envName}`, @@ -112,6 +148,9 @@ export class LambdaStack extends cdk.Stack { // Add GET method to /tasks tasksResource.addMethod('GET', new apigateway.LambdaIntegration(this.listTasksFunction)); + // Add POST method to /tasks + tasksResource.addMethod('POST', new apigateway.LambdaIntegration(this.createTaskFunction)); + // Output the API URL new cdk.CfnOutput(this, 'ApiUrl', { value: this.api.url, @@ -132,5 +171,12 @@ export class LambdaStack extends cdk.Stack { description: 'ARN of the list tasks Lambda function', exportName: `${props.envName}-list-tasks-function-arn`, }); + + // Output the create task function ARN + new cdk.CfnOutput(this, 'CreateTaskFunctionArn', { + value: this.createTaskFunction.functionArn, + description: 'ARN of the create task Lambda function', + exportName: `${props.envName}-create-task-function-arn`, + }); } } diff --git a/src/handlers/create-task.test.ts b/src/handlers/create-task.test.ts new file mode 100644 index 0000000..601663a --- /dev/null +++ b/src/handlers/create-task.test.ts @@ -0,0 +1,374 @@ +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; + +import { Task } from '../models/task'; + +// Mock dependencies BEFORE importing handler +const mockCreateTask = 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', () => ({ + createTask: mockCreateTask, +})); + +jest.mock('../utils/logger', () => ({ + logger: { + info: mockLoggerInfo, + warn: mockLoggerWarn, + error: mockLoggerError, + }, +})); + +describe('create-task handler', () => { + let handler: typeof import('./create-task').handler; + + beforeEach(() => { + jest.clearAllMocks(); + + // Import handler after mocks are set up + handler = require('./create-task').handler; + }); + + const createMockEvent = (overrides?: Partial): APIGatewayProxyEvent => { + return { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/tasks', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: { + accountId: '123456789012', + apiId: 'test-api-id', + authorizer: null, + protocol: 'HTTP/1.1', + httpMethod: 'POST', + 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', + stage: 'test', + requestId: 'test-request-id', + requestTimeEpoch: Date.now(), + resourceId: 'test-resource-id', + resourcePath: '/tasks', + }, + resource: '/tasks', + ...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 create a task and return 201 with created task', async () => { + // Arrange + const requestBody = { + title: 'Test Task', + detail: 'Test detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + }; + + const mockTask: Task = { + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + detail: 'Test detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + createdAt: '2025-12-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockCreateTask.mockResolvedValue(mockTask); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(201); + expect(JSON.parse(result.body)).toEqual(mockTask); + expect(mockCreateTask).toHaveBeenCalledTimes(1); + expect(mockCreateTask).toHaveBeenCalledWith(requestBody); + expect(mockLoggerInfo).toHaveBeenCalledWith('[CreateTask] > handler', expect.any(Object)); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[CreateTask] < handler - successfully created task', + expect.any(Object), + ); + }); + + it('should create a task with only required fields', async () => { + // Arrange + const requestBody = { + title: 'Test Task', + }; + + const mockTask: Task = { + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + isComplete: false, + createdAt: '2025-12-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }; + + mockCreateTask.mockResolvedValue(mockTask); + const event = createMockEvent({ body: JSON.stringify(requestBody) }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(201); + expect(JSON.parse(result.body)).toEqual(mockTask); + expect(mockCreateTask).toHaveBeenCalledTimes(1); + }); + + 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(mockCreateTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith('[CreateTask] < 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(mockCreateTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith( + '[CreateTask] < handler - invalid JSON in request body', + expect.any(Object), + ); + }); + + it('should return 400 when title is missing', async () => { + // Arrange + const requestBody = { + detail: 'Test 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('title'); + expect(mockCreateTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith('[CreateTask] < handler - validation error', expect.any(Object)); + }); + + it('should return 400 when title is empty', async () => { + // Arrange + const requestBody = { + title: '', + }; + + 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(mockCreateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when title exceeds 100 characters', async () => { + // Arrange + const requestBody = { + title: 'a'.repeat(101), + }; + + 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('100 characters'); + expect(mockCreateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when detail exceeds 1000 characters', async () => { + // Arrange + const requestBody = { + title: 'Test Task', + detail: 'a'.repeat(1001), + }; + + 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('1000 characters'); + expect(mockCreateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when dueAt is not a valid ISO8601 timestamp', async () => { + // Arrange + const requestBody = { + title: 'Test Task', + dueAt: '2025-12-31', + }; + + 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('ISO8601'); + expect(mockCreateTask).not.toHaveBeenCalled(); + }); + + it('should return 400 when isComplete is not a boolean', async () => { + // Arrange + const requestBody = { + title: 'Test Task', + isComplete: 'true', + }; + + 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(mockCreateTask).not.toHaveBeenCalled(); + }); + + it('should return 500 when service throws an error', async () => { + // Arrange + const requestBody = { + title: 'Test Task', + }; + + const mockError = new Error('DynamoDB error'); + mockCreateTask.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 create task' }); + expect(mockCreateTask).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalledWith( + '[CreateTask] < handler - failed to create task', + mockError, + expect.any(Object), + ); + }); + + it('should handle multiple validation errors', async () => { + // Arrange + const requestBody = { + title: '', + detail: 'a'.repeat(1001), + }; + + 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(mockCreateTask).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/handlers/create-task.ts b/src/handlers/create-task.ts new file mode 100644 index 0000000..6b559c2 --- /dev/null +++ b/src/handlers/create-task.ts @@ -0,0 +1,77 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { lambdaRequestTracker } from 'pino-lambda'; +import { ZodError } from 'zod'; + +import { CreateTaskDtoSchema } from '../models/create-task-dto.js'; +import { createTask } from '../services/task-service.js'; +import { badRequest, created, internalServerError } 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 creating a new task + * Handles POST requests from API Gateway to create a task in DynamoDB + * + * @param event - API Gateway proxy event + * @returns API Gateway proxy result with created task or error message + */ +export const handler = async (event: APIGatewayProxyEvent, context: Context): Promise => { + withRequestTracking(event, context); + logger.info('[CreateTask] > handler', { + requestId: event.requestContext.requestId, + event, + }); + + try { + // Parse and validate request body + if (!event.body) { + logger.warn('[CreateTask] < 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('[CreateTask] < handler - invalid JSON in request body', { + requestId: event.requestContext.requestId, + }); + return badRequest('Invalid JSON in request body'); + } + + // Validate request body against schema + const validatedDto = CreateTaskDtoSchema.parse(requestBody); + + // Create the task + const task = await createTask(validatedDto); + + logger.info('[CreateTask] < handler - successfully created task', { + id: task.id, + requestId: event.requestContext.requestId, + }); + + return created(task); + } catch (error) { + if (error instanceof ZodError) { + const errorMessages = error.issues.map((err) => `${err.path.join('.')}: ${err.message}`).join(', '); + logger.warn('[CreateTask] < handler - validation error', { + errors: error.issues, + requestId: event.requestContext.requestId, + }); + return badRequest(`Validation failed: ${errorMessages}`); + } + + logger.error('[CreateTask] < handler - failed to create task', error as Error, { + requestId: event.requestContext.requestId, + }); + + return internalServerError('Failed to create task'); + } +}; diff --git a/src/models/create-task-dto.test.ts b/src/models/create-task-dto.test.ts new file mode 100644 index 0000000..d19062e --- /dev/null +++ b/src/models/create-task-dto.test.ts @@ -0,0 +1,251 @@ +import { CreateTaskDtoSchema } from './create-task-dto'; + +describe('create-task-dto', () => { + describe('CreateTaskDtoSchema', () => { + it('should validate a valid create 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 = CreateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual(validDto); + } + }); + + it('should validate a valid create task DTO with only title', () => { + // Arrange + const validDto = { + title: 'Test Task', + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.title).toBe('Test Task'); + expect(result.data.isComplete).toBe(false); // Default value + } + }); + + it('should validate a valid create task DTO with optional fields', () => { + // Arrange + const validDto = { + title: 'Test Task', + detail: 'Test detail', + isComplete: true, + }; + + // Act + const result = CreateTaskDtoSchema.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', + }; + + // Act + const result = CreateTaskDtoSchema.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: '', + }; + + // Act + const result = CreateTaskDtoSchema.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), + }; + + // Act + const result = CreateTaskDtoSchema.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 accept title with exactly 100 characters', () => { + // Arrange + const validDto = { + title: 'a'.repeat(100), + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + }); + + it('should reject when detail exceeds 1000 characters', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + detail: 'a'.repeat(1001), + }; + + // Act + const result = CreateTaskDtoSchema.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 accept detail with exactly 1000 characters', () => { + // Arrange + const validDto = { + title: 'Test Task', + detail: 'a'.repeat(1000), + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + }); + + it('should reject when dueAt is not a valid ISO8601 timestamp', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + dueAt: '2025-12-31', + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('ISO8601'); + } + }); + + it('should reject when dueAt is not a valid date string', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + dueAt: 'invalid-date', + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toContain('ISO8601'); + } + }); + + it('should accept valid ISO8601 timestamp with timezone', () => { + // Arrange + const validDto = { + title: 'Test Task', + dueAt: '2025-12-31T23:59:59.000Z', + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + }); + + it('should reject when isComplete is not a boolean', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + isComplete: 'true', + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].path).toEqual(['isComplete']); + } + }); + + it('should accept isComplete as true', () => { + // Arrange + const validDto = { + title: 'Test Task', + isComplete: true, + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(validDto); + + // Assert + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.isComplete).toBe(true); + } + }); + + it('should reject when extra fields are provided', () => { + // Arrange + const invalidDto = { + title: 'Test Task', + extraField: 'should not be here', + }; + + // Act + const result = CreateTaskDtoSchema.safeParse(invalidDto); + + // Assert + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].code).toBe('unrecognized_keys'); + } + }); + }); +}); diff --git a/src/models/create-task-dto.ts b/src/models/create-task-dto.ts new file mode 100644 index 0000000..53b0cc0 --- /dev/null +++ b/src/models/create-task-dto.ts @@ -0,0 +1,18 @@ +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(); + +/** + * Type representing the validated create task DTO + */ +export type CreateTaskDto = z.infer; diff --git a/src/services/task-service.test.ts b/src/services/task-service.test.ts index b0be598..fc5caa6 100644 --- a/src/services/task-service.test.ts +++ b/src/services/task-service.test.ts @@ -1,9 +1,15 @@ +import { CreateTaskDto } from '../models/create-task-dto'; import { TaskItem } from '../models/task'; // Mock dependencies const mockSend = jest.fn(); const mockLoggerInfo = jest.fn(); const mockLoggerError = jest.fn(); +const mockRandomUUID = jest.fn(); + +jest.mock('crypto', () => ({ + randomUUID: mockRandomUUID, +})); jest.mock('../utils/dynamodb-client', () => ({ dynamoDocClient: { @@ -26,13 +32,16 @@ jest.mock('../utils/config', () => ({ describe('task-service', () => { let listTasks: typeof import('./task-service').listTasks; + let createTask: typeof import('./task-service').createTask; beforeEach(() => { // Clear all mocks jest.clearAllMocks(); // Import the module after mocks are set up - listTasks = require('./task-service').listTasks; + const taskService = require('./task-service'); + listTasks = taskService.listTasks; + createTask = taskService.createTask; }); describe('listTasks', () => { @@ -120,4 +129,191 @@ describe('task-service', () => { expect(mockSend).toHaveBeenCalledTimes(1); }); }); + + describe('createTask', () => { + beforeEach(() => { + // Mock Date.now() for consistent timestamps + jest.useFakeTimers(); + jest.setSystemTime(new Date('2025-12-01T10:00:00.000Z')); + + // Mock UUID generation + mockRandomUUID.mockReturnValue('123e4567-e89b-12d3-a456-426614174000'); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should create a task with all fields', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + detail: 'Test detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + }; + + mockSend.mockResolvedValue({}); + + // Act + const result = await createTask(createTaskDto); + + // Assert + expect(result).toEqual({ + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + detail: 'Test detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + createdAt: '2025-12-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + TableName: 'test-tasks-table', + Item: expect.objectContaining({ + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + detail: 'Test detail', + dueAt: '2025-12-31T23:59:59.000Z', + isComplete: false, + createdAt: '2025-12-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }), + }), + }), + ); + expect(mockLoggerInfo).toHaveBeenCalledWith('[TaskService] > createTask', { + tableName: 'test-tasks-table', + }); + }); + + it('should create a task with only required fields', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + isComplete: false, + }; + + mockSend.mockResolvedValue({}); + + // Act + const result = await createTask(createTaskDto); + + // Assert + expect(result).toEqual({ + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + isComplete: false, + createdAt: '2025-12-01T10:00:00.000Z', + updatedAt: '2025-12-01T10:00:00.000Z', + }); + // Task should not include optional fields that were not provided + expect(result).not.toHaveProperty('detail'); + expect(result).not.toHaveProperty('dueAt'); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should create a task with isComplete defaulting to false when undefined', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + isComplete: false, + }; + + mockSend.mockResolvedValue({}); + + // Act + const result = await createTask(createTaskDto); + + // Assert + expect(result.isComplete).toBe(false); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should create a task with isComplete set to true', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + isComplete: true, + }; + + mockSend.mockResolvedValue({}); + + // Act + const result = await createTask(createTaskDto); + + // Assert + expect(result.isComplete).toBe(true); + expect(mockSend).toHaveBeenCalledTimes(1); + }); + + it('should generate a unique UUID for each task', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + isComplete: false, + }; + + mockSend.mockResolvedValue({}); + + // Act + await createTask(createTaskDto); + + // Assert + expect(mockRandomUUID).toHaveBeenCalledTimes(1); + }); + + it('should set createdAt and updatedAt to the current time', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + isComplete: false, + }; + + mockSend.mockResolvedValue({}); + + // Act + const result = await createTask(createTaskDto); + + // Assert + expect(result.createdAt).toBe('2025-12-01T10:00:00.000Z'); + expect(result.updatedAt).toBe('2025-12-01T10:00:00.000Z'); + }); + + it('should handle DynamoDB errors and rethrow them', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + isComplete: false, + }; + + const mockError = new Error('DynamoDB error'); + mockSend.mockRejectedValue(mockError); + + // Act & Assert + await expect(createTask(createTaskDto)).rejects.toThrow('DynamoDB error'); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalled(); + }); + + it('should not include pk field in returned task', async () => { + // Arrange + const createTaskDto: CreateTaskDto = { + title: 'Test Task', + isComplete: false, + }; + + mockSend.mockResolvedValue({}); + + // Act + const result = await createTask(createTaskDto); + + // Assert + expect(result).not.toHaveProperty('pk'); + }); + }); }); diff --git a/src/services/task-service.ts b/src/services/task-service.ts index 5605413..e3c2d37 100644 --- a/src/services/task-service.ts +++ b/src/services/task-service.ts @@ -1,6 +1,8 @@ -import { ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { randomUUID } from 'crypto'; +import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; -import { Task, TaskItem, toTask } from '../models/task.js'; +import { CreateTaskDto } from '../models/create-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'; import { logger } from '../utils/logger.js'; @@ -36,3 +38,49 @@ export const listTasks = async (): Promise => { throw error; } }; + +/** + * Creates a new task in the DynamoDB table + * @param createTaskDto - The data for the new task + * @returns Promise that resolves to the created Task object + * @throws Error if the DynamoDB put operation fails + */ +export const createTask = async (createTaskDto: CreateTaskDto): Promise => { + logger.info('[TaskService] > createTask', { tableName: config.TASKS_TABLE }); + + try { + const id = randomUUID(); + const now = new Date().toISOString(); + + const taskItem: TaskItem = { + pk: TaskKeys.pk(id), + id, + title: createTaskDto.title, + ...(createTaskDto.detail && { detail: createTaskDto.detail }), + ...(createTaskDto.dueAt && { dueAt: createTaskDto.dueAt }), + isComplete: createTaskDto.isComplete ?? false, + createdAt: now, + updatedAt: now, + }; + + const command = new PutCommand({ + TableName: config.TASKS_TABLE, + Item: taskItem, + }); + + await dynamoDocClient.send(command); + + const task = toTask(taskItem); + + logger.info('[TaskService] < createTask - successfully created task', { + id: task.id, + }); + + return task; + } catch (error) { + logger.error('[TaskService] < createTask - failed to create task in DynamoDB', error as Error, { + tableName: config.TASKS_TABLE, + }); + throw error; + } +}; From 08be1babe41ee477d05aa06d1e030e12da2dba75 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 1 Dec 2025 15:34:15 -0500 Subject: [PATCH 2/5] Add debug logging for ScanCommand and PutCommand in task service --- src/services/task-service.test.ts | 2 ++ src/services/task-service.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/services/task-service.test.ts b/src/services/task-service.test.ts index fc5caa6..f24d5ee 100644 --- a/src/services/task-service.test.ts +++ b/src/services/task-service.test.ts @@ -3,6 +3,7 @@ import { TaskItem } from '../models/task'; // Mock dependencies const mockSend = jest.fn(); +const mockLoggerDebug = jest.fn(); const mockLoggerInfo = jest.fn(); const mockLoggerError = jest.fn(); const mockRandomUUID = jest.fn(); @@ -19,6 +20,7 @@ jest.mock('../utils/dynamodb-client', () => ({ jest.mock('../utils/logger', () => ({ logger: { + debug: mockLoggerDebug, info: mockLoggerInfo, error: mockLoggerError, }, diff --git a/src/services/task-service.ts b/src/services/task-service.ts index e3c2d37..51df70a 100644 --- a/src/services/task-service.ts +++ b/src/services/task-service.ts @@ -19,6 +19,7 @@ export const listTasks = async (): Promise => { const command = new ScanCommand({ TableName: config.TASKS_TABLE, }); + logger.debug('[TaskService] listTasks - ScanCommand', { command }); const response = await dynamoDocClient.send(command); @@ -67,6 +68,7 @@ export const createTask = async (createTaskDto: CreateTaskDto): Promise => TableName: config.TASKS_TABLE, Item: taskItem, }); + logger.debug('[TaskService] createTask - PutCommand', { command }); await dynamoDocClient.send(command); From a9348ed02aaff01b87319601cc526b21aff163f5 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 1 Dec 2025 15:39:42 -0500 Subject: [PATCH 3/5] Update Infrastructure Guide with Create Task function details and IAM permissions --- docs/InfrastructureGuide.md | 56 +++++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/docs/InfrastructureGuide.md b/docs/InfrastructureGuide.md index 9484f5e..6ddbf61 100644 --- a/docs/InfrastructureGuide.md +++ b/docs/InfrastructureGuide.md @@ -420,6 +420,45 @@ const tableName = process.env.TASK_TABLE_NAME; - `prd`: `RETAIN` (logs preserved on stack deletion) - Other environments: `DESTROY` (logs deleted on stack deletion) +**IAM Permissions**: + +- **DynamoDB**: Read access (Scan) to the Task table +- **CloudWatch Logs**: Write access to its log group + +#### Create Task Function + +**Resource Type**: AWS Lambda Function + +**Configuration**: + +- **Function Name**: `{app-name}-create-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}-create-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**: Write access (PutItem) to the Task table +- **CloudWatch Logs**: Write access to its log group + #### Lambda Starter API **Resource Type**: API Gateway REST API @@ -440,14 +479,20 @@ const tableName = process.env.TASK_TABLE_NAME; **API Resources**: - **GET /tasks**: List all tasks - - Integration: Lambda proxy integration + - Integration: Lambda proxy integration with List Tasks Function - Response: JSON array of tasks +- **POST /tasks**: Create a new task + - Integration: Lambda proxy integration with Create Task Function + - Request Body: JSON object with task properties (title, description, status) + - Response: JSON object with created task including ID and timestamps + **Outputs**: - `ApiUrl`: The API Gateway endpoint URL (e.g., `https://abc123.execute-api.us-east-1.amazonaws.com/dev/`) - `ApiId`: The API Gateway ID -- `ListTasksFunctionArn`: The Lambda function ARN +- `ListTasksFunctionArn`: The List Tasks Lambda function ARN +- `CreateTaskFunctionArn`: The Create Task Lambda function ARN **Logging Configuration**: @@ -486,13 +531,6 @@ CDK_APP_LOGGING_FORMAT=json CDK_APP_ENABLE_LOGGING=false ``` -**IAM Permissions**: - -The Lambda function is granted: - -- **DynamoDB**: Read access (Scan) to the Task table -- **CloudWatch Logs**: Write access to its log group - ### Resource Tagging All resources are automatically tagged: From d5d246db78bafa9d11246aba6de60e9494c3b8b5 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Mon, 1 Dec 2025 16:19:14 -0500 Subject: [PATCH 4/5] Add GitHub Actions workflow for deploying to DEV environment --- .github/workflows/deploy-dev.yml | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/deploy-dev.yml diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml new file mode 100644 index 0000000..c708eda --- /dev/null +++ b/.github/workflows/deploy-dev.yml @@ -0,0 +1,85 @@ +name: Deploy to DEV + +on: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + timeout-minutes: 15 + + permissions: + contents: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: 'npm' + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v5.1.1 + with: + role-to-assume: ${{ vars.AWS_ROLE_ARN_DEV }} + aws-region: ${{ vars.AWS_REGION }} + role-session-name: deploy-dev-lambda-starter + + - name: Install dependencies + run: npm ci + + - name: Build application + run: npm run build + + - name: Run tests + run: npm run test + + - name: Install infrastructure dependencies + working-directory: ./infrastructure + run: npm ci + + - name: Create infrastructure .env file + working-directory: ./infrastructure + run: echo "${{ vars.CDK_ENV_DEV }}" > .env + + - name: Build infrastructure + working-directory: ./infrastructure + run: npm run build + + - name: Bootstrap CDK (if needed) + working-directory: ./infrastructure + run: | + # Check if bootstrap is needed + if ! aws cloudformation describe-stacks --stack-name CDKToolkit --region ${{ vars.AWS_REGION }} >/dev/null 2>&1; then + echo "Bootstrapping CDK..." + npm run bootstrap + else + echo "CDK already bootstrapped" + fi + + - name: Synthesize CDK stacks + working-directory: ./infrastructure + run: npm run synth + + - name: Deploy CDK stacks + working-directory: ./infrastructure + run: npm run deploy:all -- --require-approval never --progress events + + # Final Step: Clean up sensitive infrastructure files + - name: Clean up sensitive files + if: always() + working-directory: ./infrastructure + run: | + echo "🧹 Cleaning up sensitive files..." + rm -f .env + rm -rf cdk.out + echo "✅ Sensitive files cleaned up" From 67712f1b75323fbcf48deb3af818c3bd43839e4e Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Tue, 2 Dec 2025 05:57:14 -0500 Subject: [PATCH 5/5] Refactor concurrency group in deploy workflow for consistency --- .github/workflows/deploy-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index c708eda..2688ded 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }} cancel-in-progress: false jobs: