diff --git a/docs/InfrastructureGuide.md b/docs/InfrastructureGuide.md index 6ddbf61..5ec0edb 100644 --- a/docs/InfrastructureGuide.md +++ b/docs/InfrastructureGuide.md @@ -425,6 +425,40 @@ const tableName = process.env.TASK_TABLE_NAME; - **DynamoDB**: Read access (Scan) to the Task table - **CloudWatch Logs**: Write access to its log group +#### Get Task Function + +**Resource Type**: AWS Lambda Function + +**Configuration**: + +- **Function Name**: `{app-name}-get-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}-get-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 access (GetItem) to the Task table +- **CloudWatch Logs**: Write access to its log group + #### Create Task Function **Resource Type**: AWS Lambda Function @@ -482,6 +516,14 @@ const tableName = process.env.TASK_TABLE_NAME; - Integration: Lambda proxy integration with List Tasks Function - Response: JSON array of tasks +- **GET /tasks/{taskId}**: Get a specific task by ID + - Integration: Lambda proxy integration with Get Task Function + - Path Parameter: `taskId` - The unique identifier of the task + - Response: JSON object with the requested task + - Error Responses: + - 404 Not Found: Task ID does not exist or path parameter is missing + - 500 Internal Server Error: Failed to retrieve task + - **POST /tasks**: Create a new task - Integration: Lambda proxy integration with Create Task Function - Request Body: JSON object with task properties (title, description, status) @@ -492,6 +534,7 @@ const tableName = process.env.TASK_TABLE_NAME; - `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 List Tasks Lambda function ARN +- `GetTaskFunctionArn`: The Get Task Lambda function ARN - `CreateTaskFunctionArn`: The Create Task Lambda function ARN **Logging Configuration**: diff --git a/eslint.config.mjs b/eslint.config.mjs index 8d9288f..851c163 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,7 @@ import eslintConfigPrettier from 'eslint-config-prettier/flat'; export default defineConfig( { - ignores: ['dist', 'coverage', 'cdk.out'], + ignores: ['**/dist', '**/coverage', '**/cdk.out'], }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], diff --git a/infrastructure/stacks/lambda-stack.test.ts b/infrastructure/stacks/lambda-stack.test.ts index 55f114a..e675dd7 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 get task Lambda function', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + FunctionName: 'lambda-starter-get-task-dev', + Runtime: 'nodejs24.x', + Handler: 'handler', + Timeout: 10, + MemorySize: 256, + }); + }); + it('should create a create task Lambda function', () => { template.hasResourceProperties('AWS::Lambda::Function', { FunctionName: 'lambda-starter-create-task-dev', @@ -88,12 +98,18 @@ describe('LambdaStack', () => { }); it('should create a /tasks resource', () => { - template.resourceCountIs('AWS::ApiGateway::Resource', 1); + template.resourceCountIs('AWS::ApiGateway::Resource', 2); template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'tasks', }); }); + it('should create a /tasks/{taskId} resource', () => { + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: '{taskId}', + }); + }); + it('should create a GET method on /tasks', () => { template.hasResourceProperties('AWS::ApiGateway::Method', { HttpMethod: 'GET', @@ -173,7 +189,7 @@ describe('LambdaStack', () => { it('should export API URL', () => { template.hasOutput('ApiUrl', { Export: { - Name: 'dev-tasks-api-url', + Name: 'lambda-starter-tasks-api-url-dev', }, }); }); @@ -181,7 +197,7 @@ describe('LambdaStack', () => { it('should export API ID', () => { template.hasOutput('ApiId', { Export: { - Name: 'dev-tasks-api-id', + Name: 'lambda-starter-tasks-api-id-dev', }, }); }); @@ -189,7 +205,7 @@ describe('LambdaStack', () => { it('should export Lambda function ARN', () => { template.hasOutput('ListTasksFunctionArn', { Export: { - Name: 'dev-list-tasks-function-arn', + Name: 'lambda-starter-list-tasks-function-arn-dev', }, }); }); @@ -197,7 +213,15 @@ describe('LambdaStack', () => { it('should export create task function ARN', () => { template.hasOutput('CreateTaskFunctionArn', { Export: { - Name: 'dev-create-task-function-arn', + Name: 'lambda-starter-create-task-function-arn-dev', + }, + }); + }); + + it('should export get task function ARN', () => { + template.hasOutput('GetTaskFunctionArn', { + Export: { + Name: 'lambda-starter-get-task-function-arn-dev', }, }); }); diff --git a/infrastructure/stacks/lambda-stack.ts b/infrastructure/stacks/lambda-stack.ts index 7b04e0b..bab1374 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 get task Lambda function. + */ + public readonly getTaskFunction: NodejsFunction; + /** * The create task Lambda function. */ @@ -95,6 +100,37 @@ export class LambdaStack extends cdk.Stack { // Grant the Lambda function read access to the DynamoDB table props.taskTable.grantReadData(this.listTasksFunction); + // Create the get task Lambda function + this.getTaskFunction = new NodejsFunction(this, 'GetTaskFunction', { + functionName: `${props.appName}-get-task-${props.envName}`, + runtime: lambda.Runtime.NODEJS_24_X, + handler: 'handler', + entry: path.join(__dirname, '../../src/handlers/get-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, 'GetTaskFunctionLogGroup', { + logGroupName: `/aws/lambda/${props.appName}-get-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 access to the DynamoDB table + props.taskTable.grantReadData(this.getTaskFunction); + // Create the create task Lambda function this.createTaskFunction = new NodejsFunction(this, 'CreateTaskFunction', { functionName: `${props.appName}-create-task-${props.envName}`, @@ -151,32 +187,45 @@ export class LambdaStack extends cdk.Stack { // Add POST method to /tasks tasksResource.addMethod('POST', new apigateway.LambdaIntegration(this.createTaskFunction)); + // Create /tasks/{taskId} resource + const taskResource = tasksResource.addResource('{taskId}'); + + // Add GET method to /tasks/{taskId} + taskResource.addMethod('GET', new apigateway.LambdaIntegration(this.getTaskFunction)); + // Output the API URL new cdk.CfnOutput(this, 'ApiUrl', { value: this.api.url, description: 'URL of the Tasks API', - exportName: `${props.envName}-tasks-api-url`, + exportName: `${props.appName}-tasks-api-url-${props.envName}`, }); // Output the API Gateway ID new cdk.CfnOutput(this, 'ApiId', { value: this.api.restApiId, description: 'ID of the Tasks API', - exportName: `${props.envName}-tasks-api-id`, + exportName: `${props.appName}-tasks-api-id-${props.envName}`, }); // Output the list tasks function ARN new cdk.CfnOutput(this, 'ListTasksFunctionArn', { value: this.listTasksFunction.functionArn, description: 'ARN of the list tasks Lambda function', - exportName: `${props.envName}-list-tasks-function-arn`, + exportName: `${props.appName}-list-tasks-function-arn-${props.envName}`, + }); + + // Output the get task function ARN + new cdk.CfnOutput(this, 'GetTaskFunctionArn', { + value: this.getTaskFunction.functionArn, + description: 'ARN of the get task Lambda function', + exportName: `${props.appName}-get-task-function-arn-${props.envName}`, }); // 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`, + exportName: `${props.appName}-create-task-function-arn-${props.envName}`, }); } } diff --git a/src/handlers/get-task.test.ts b/src/handlers/get-task.test.ts new file mode 100644 index 0000000..ebc3061 --- /dev/null +++ b/src/handlers/get-task.test.ts @@ -0,0 +1,324 @@ +import { APIGatewayProxyEvent, Context } from 'aws-lambda'; + +import { Task } from '../models/task'; + +// Mock dependencies BEFORE importing handler +const mockGetTask = 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', () => ({ + getTask: mockGetTask, +})); + +jest.mock('../utils/logger', () => ({ + logger: { + info: mockLoggerInfo, + warn: mockLoggerWarn, + error: mockLoggerError, + }, +})); + +describe('get-task handler', () => { + let handler: typeof import('./get-task').handler; + + beforeEach(() => { + jest.clearAllMocks(); + + // Import handler after mocks are set up + handler = require('./get-task').handler; + }); + + const createMockEvent = (overrides?: Partial): APIGatewayProxyEvent => { + return { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'GET', + 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: 'GET', + 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 return task when it exists', async () => { + // Arrange + const mockTask: Task = { + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + detail: 'Test detail', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }; + + mockGetTask.mockResolvedValue(mockTask); + const event = createMockEvent(); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual(mockTask); + expect(mockGetTask).toHaveBeenCalledTimes(1); + expect(mockGetTask).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000'); + expect(mockLoggerInfo).toHaveBeenCalledWith('[GetTask] > handler', expect.any(Object)); + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[GetTask] < handler - successfully retrieved task', + expect.any(Object), + ); + }); + + it('should return 404 when task does not exist', async () => { + // Arrange + mockGetTask.mockResolvedValue(null); + const event = createMockEvent(); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(404); + expect(JSON.parse(result.body)).toEqual({ + message: 'Task not found', + }); + expect(mockGetTask).toHaveBeenCalledTimes(1); + expect(mockGetTask).toHaveBeenCalledWith('123e4567-e89b-12d3-a456-426614174000'); + expect(mockLoggerInfo).toHaveBeenCalledWith('[GetTask] < handler - task not found', expect.any(Object)); + }); + + it('should return 404 when taskId path parameter is missing', async () => { + // Arrange + const event = createMockEvent({ + pathParameters: null, + }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(404); + expect(JSON.parse(result.body)).toEqual({ + message: 'Task not found', + }); + expect(mockGetTask).not.toHaveBeenCalled(); + expect(mockLoggerWarn).toHaveBeenCalledWith( + '[GetTask] < handler - missing taskId path parameter', + expect.any(Object), + ); + }); + + it('should return 404 when taskId is undefined', async () => { + // Arrange + const event = createMockEvent({ + pathParameters: {}, + }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(404); + expect(JSON.parse(result.body)).toEqual({ + message: 'Task not found', + }); + expect(mockGetTask).not.toHaveBeenCalled(); + }); + + it('should return 500 error when service throws an error', async () => { + // Arrange + const mockError = new Error('Service error'); + mockGetTask.mockRejectedValue(mockError); + const event = createMockEvent(); + 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 retrieve task', + }); + expect(mockGetTask).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalledWith( + '[GetTask] < handler - failed to get task', + mockError, + expect.any(Object), + ); + }); + + it('should include CORS headers in response', async () => { + // Arrange + const mockTask: Task = { + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }; + + mockGetTask.mockResolvedValue(mockTask); + const event = createMockEvent(); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.headers).toBeDefined(); + expect(result.headers?.['Content-Type']).toBe('application/json'); + expect(result.headers?.['Access-Control-Allow-Origin']).toBeDefined(); + }); + + it('should log request context information', async () => { + // Arrange + const mockTask: Task = { + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }; + + mockGetTask.mockResolvedValue(mockTask); + const event = createMockEvent(); + const context = createMockContext(); + + // Act + await handler(event, context); + + // Assert + expect(mockLoggerInfo).toHaveBeenCalledWith( + '[GetTask] > handler', + expect.objectContaining({ + requestId: 'test-request-id', + event: expect.any(Object), + }), + ); + }); + + it('should return task with only required fields', async () => { + // Arrange + const mockTask: Task = { + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }; + + mockGetTask.mockResolvedValue(mockTask); + const event = createMockEvent(); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + const body = JSON.parse(result.body); + expect(body).toEqual(mockTask); + expect(body).not.toHaveProperty('detail'); + expect(body).not.toHaveProperty('dueAt'); + }); + + it('should handle different task IDs', async () => { + // Arrange + const taskId = 'different-task-id'; + const mockTask: Task = { + id: taskId, + title: 'Different Task', + isComplete: true, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }; + + mockGetTask.mockResolvedValue(mockTask); + const event = createMockEvent({ + pathParameters: { taskId }, + path: `/tasks/${taskId}`, + }); + const context = createMockContext(); + + // Act + const result = await handler(event, context); + + // Assert + expect(result.statusCode).toBe(200); + expect(mockGetTask).toHaveBeenCalledWith(taskId); + expect(JSON.parse(result.body)).toEqual(mockTask); + }); + }); +}); diff --git a/src/handlers/get-task.ts b/src/handlers/get-task.ts new file mode 100644 index 0000000..f97ca1c --- /dev/null +++ b/src/handlers/get-task.ts @@ -0,0 +1,61 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from 'aws-lambda'; +import { lambdaRequestTracker } from 'pino-lambda'; + +import { getTask } from '../services/task-service.js'; +import { 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 retrieving a task by ID + * Handles GET requests from API Gateway to retrieve a specific task from DynamoDB + * + * @param event - API Gateway proxy event + * @returns API Gateway proxy result with task or error message + */ +export const handler = async (event: APIGatewayProxyEvent, context: Context): Promise => { + withRequestTracking(event, context); + logger.info('[GetTask] > handler', { + requestId: event.requestContext.requestId, + event, + }); + + try { + const taskId = event.pathParameters?.taskId; + + if (!taskId) { + logger.warn('[GetTask] < handler - missing taskId path parameter', { + requestId: event.requestContext.requestId, + }); + return notFound('Task not found'); + } + + const task = await getTask(taskId); + + if (!task) { + logger.info('[GetTask] < handler - task not found', { + taskId, + requestId: event.requestContext.requestId, + }); + return notFound('Task not found'); + } + + logger.info('[GetTask] < handler - successfully retrieved task', { + taskId, + requestId: event.requestContext.requestId, + }); + + return ok(task); + } catch (error) { + logger.error('[GetTask] < handler - failed to get task', error as Error, { + requestId: event.requestContext.requestId, + }); + + return internalServerError('Failed to retrieve task'); + } +}; diff --git a/src/services/task-service.test.ts b/src/services/task-service.test.ts index f24d5ee..2b3484a 100644 --- a/src/services/task-service.test.ts +++ b/src/services/task-service.test.ts @@ -34,6 +34,7 @@ jest.mock('../utils/config', () => ({ describe('task-service', () => { let listTasks: typeof import('./task-service').listTasks; + let getTask: typeof import('./task-service').getTask; let createTask: typeof import('./task-service').createTask; beforeEach(() => { @@ -43,6 +44,7 @@ describe('task-service', () => { // Import the module after mocks are set up const taskService = require('./task-service'); listTasks = taskService.listTasks; + getTask = taskService.getTask; createTask = taskService.createTask; }); @@ -132,6 +134,130 @@ describe('task-service', () => { }); }); + describe('getTask', () => { + it('should return a task when it exists', async () => { + // Arrange + const mockTaskItem: TaskItem = { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + detail: 'Test detail', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }; + + mockSend.mockResolvedValue({ + Item: mockTaskItem, + }); + + // Act + const result = await getTask('123e4567-e89b-12d3-a456-426614174000'); + + // Assert + expect(result).toEqual({ + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + detail: 'Test detail', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }); + expect(result).not.toHaveProperty('pk'); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + TableName: 'test-tasks-table', + Key: { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + }, + }), + }), + ); + expect(mockLoggerInfo).toHaveBeenCalledWith('[TaskService] > getTask', { + tableName: 'test-tasks-table', + id: '123e4567-e89b-12d3-a456-426614174000', + }); + }); + + it('should return null when task does not exist', async () => { + // Arrange + mockSend.mockResolvedValue({}); + + // Act + const result = await getTask('non-existent-id'); + + // Assert + expect(result).toBeNull(); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLoggerInfo).toHaveBeenCalledWith('[TaskService] < getTask - task not found', { + id: 'non-existent-id', + }); + }); + + it('should return a task with only required fields', async () => { + // Arrange + const mockTaskItem: TaskItem = { + pk: 'TASK#123e4567-e89b-12d3-a456-426614174000', + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }; + + mockSend.mockResolvedValue({ + Item: mockTaskItem, + }); + + // Act + const result = await getTask('123e4567-e89b-12d3-a456-426614174000'); + + // Assert + expect(result).toEqual({ + id: '123e4567-e89b-12d3-a456-426614174000', + title: 'Test Task', + isComplete: false, + createdAt: '2025-11-01T10:00:00.000Z', + updatedAt: '2025-11-01T10:00:00.000Z', + }); + expect(result).not.toHaveProperty('detail'); + expect(result).not.toHaveProperty('dueAt'); + }); + + it('should handle DynamoDB errors and rethrow them', async () => { + // Arrange + const mockError = new Error('DynamoDB error'); + mockSend.mockRejectedValue(mockError); + + // Act & Assert + await expect(getTask('123e4567-e89b-12d3-a456-426614174000')).rejects.toThrow('DynamoDB error'); + expect(mockSend).toHaveBeenCalledTimes(1); + expect(mockLoggerError).toHaveBeenCalled(); + }); + + it('should construct correct DynamoDB key with task ID', async () => { + // Arrange + const taskId = 'test-task-id-123'; + mockSend.mockResolvedValue({}); + + // Act + await getTask(taskId); + + // Assert + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + Key: { + pk: 'TASK#test-task-id-123', + }, + }), + }), + ); + }); + }); + describe('createTask', () => { beforeEach(() => { // Mock Date.now() for consistent timestamps diff --git a/src/services/task-service.ts b/src/services/task-service.ts index 51df70a..ce32cab 100644 --- a/src/services/task-service.ts +++ b/src/services/task-service.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'crypto'; -import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { GetCommand, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; import { CreateTaskDto } from '../models/create-task-dto.js'; import { Task, TaskItem, TaskKeys, toTask } from '../models/task.js'; @@ -40,6 +40,45 @@ export const listTasks = async (): Promise => { } }; +/** + * Retrieves a task by ID from the DynamoDB table + * @param id - The unique identifier of the task + * @returns Promise that resolves to the Task object if found, or null if not found + * @throws Error if the DynamoDB get operation fails + */ +export const getTask = async (id: string): Promise => { + logger.info('[TaskService] > getTask', { tableName: config.TASKS_TABLE, id }); + + try { + const command = new GetCommand({ + TableName: config.TASKS_TABLE, + Key: { + pk: TaskKeys.pk(id), + }, + }); + logger.debug('[TaskService] getTask - GetCommand', { command }); + + const response = await dynamoDocClient.send(command); + + if (!response.Item) { + logger.info('[TaskService] < getTask - task not found', { id }); + return null; + } + + const task = toTask(response.Item as TaskItem); + + logger.info('[TaskService] < getTask - successfully retrieved task', { id }); + + return task; + } catch (error) { + logger.error('[TaskService] < getTask - failed to fetch task from DynamoDB', error as Error, { + tableName: config.TASKS_TABLE, + id, + }); + throw error; + } +}; + /** * Creates a new task in the DynamoDB table * @param createTaskDto - The data for the new task