diff --git a/infrastructure/terraform/components/api/README.md b/infrastructure/terraform/components/api/README.md
index c05256f55..1b60ce0ac 100644
--- a/infrastructure/terraform/components/api/README.md
+++ b/infrastructure/terraform/components/api/README.md
@@ -34,6 +34,7 @@ No requirements.
|------|--------|---------|
| [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
| [domain\_truststore](#module\_domain\_truststore) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-s3bucket.zip | n/a |
+| [get\_letter](#module\_get\_letter) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
| [get\_letter\_data](#module\_get\_letter\_data) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
| [get\_letters](#module\_get\_letters) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip | n/a |
| [kms](#module\_kms) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.20/terraform-kms.zip | n/a |
diff --git a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
index 7faefb311..c4cd1b169 100644
--- a/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
+++ b/infrastructure/terraform/components/api/iam_role_api_gateway_execution_role.tf
@@ -49,6 +49,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" {
resources = [
module.authorizer_lambda.function_arn,
+ module.get_letter.function_arn,
module.get_letters.function_arn,
module.patch_letter.function_arn,
module.get_letter_data.function_arn
diff --git a/infrastructure/terraform/components/api/locals.tf b/infrastructure/terraform/components/api/locals.tf
index c094c52f9..5a2b4aa56 100644
--- a/infrastructure/terraform/components/api/locals.tf
+++ b/infrastructure/terraform/components/api/locals.tf
@@ -8,6 +8,7 @@ locals {
APIG_EXECUTION_ROLE_ARN = aws_iam_role.api_gateway_execution_role.arn
AWS_REGION = var.region
AUTHORIZER_LAMBDA_ARN = module.authorizer_lambda.function_arn
+ GET_LETTER_LAMBDA_ARN = module.get_letter.function_arn
GET_LETTERS_LAMBDA_ARN = module.get_letters.function_arn
GET_LETTER_DATA_LAMBDA_ARN = module.get_letter_data.function_arn
PATCH_LETTER_LAMBDA_ARN = module.patch_letter.function_arn
diff --git a/infrastructure/terraform/components/api/module_lambda_get_letter.tf b/infrastructure/terraform/components/api/module_lambda_get_letter.tf
new file mode 100644
index 000000000..a92a60515
--- /dev/null
+++ b/infrastructure/terraform/components/api/module_lambda_get_letter.tf
@@ -0,0 +1,69 @@
+module "get_letter" {
+ source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.24/terraform-lambda.zip"
+
+ function_name = "get_letter"
+ description = "Get letter status"
+
+ aws_account_id = var.aws_account_id
+ component = var.component
+ environment = var.environment
+ project = var.project
+ region = var.region
+ group = var.group
+
+ log_retention_in_days = var.log_retention_in_days
+ kms_key_arn = module.kms.key_arn
+
+ iam_policy_document = {
+ body = data.aws_iam_policy_document.get_letter_lambda.json
+ }
+
+ function_s3_bucket = local.acct.s3_buckets["lambda_function_artefacts"]["id"]
+ function_code_base_path = local.aws_lambda_functions_dir_path
+ function_code_dir = "api-handler/dist"
+ function_include_common = true
+ handler_function_name = "getLetter"
+ runtime = "nodejs22.x"
+ memory = 128
+ timeout = 5
+ log_level = var.log_level
+
+ force_lambda_code_deploy = var.force_lambda_code_deploy
+ enable_lambda_insights = false
+
+ send_to_firehose = true
+ log_destination_arn = local.destination_arn
+ log_subscription_role_arn = local.acct.log_subscription_role_arn
+
+ lambda_env_vars = merge(local.common_lambda_env_vars, {})
+}
+
+data "aws_iam_policy_document" "get_letter_lambda" {
+ statement {
+ sid = "KMSPermissions"
+ effect = "Allow"
+
+ actions = [
+ "kms:Decrypt",
+ "kms:GenerateDataKey",
+ ]
+
+ resources = [
+ module.kms.key_arn, ## Requires shared kms module
+ ]
+ }
+
+ statement {
+ sid = "AllowDynamoDBAccess"
+ effect = "Allow"
+
+ actions = [
+ "dynamodb:GetItem",
+ "dynamodb:Query"
+ ]
+
+ resources = [
+ aws_dynamodb_table.letters.arn
+ ]
+ }
+}
diff --git a/infrastructure/terraform/components/api/resources/spec.tmpl.json b/infrastructure/terraform/components/api/resources/spec.tmpl.json
index a2284fe9f..50896857a 100644
--- a/infrastructure/terraform/components/api/resources/spec.tmpl.json
+++ b/infrastructure/terraform/components/api/resources/spec.tmpl.json
@@ -54,6 +54,43 @@
}
},
"/letters/{id}": {
+ "get": {
+ "description": "Returns 200 OK with letter status.",
+ "responses": {
+ "200": {
+ "description": "OK"
+ },
+ "400": {
+ "description": "Bad request, invalid input data"
+ },
+ "404": {
+ "description": "Resource not found"
+ },
+ "500": {
+ "description": "Server error"
+ }
+ },
+ "security": [
+ {
+ "LambdaAuthorizer": []
+ }
+ ],
+ "summary": "Get letter",
+ "x-amazon-apigateway-integration": {
+ "contentHandling": "CONVERT_TO_TEXT",
+ "credentials": "${APIG_EXECUTION_ROLE_ARN}",
+ "httpMethod": "POST",
+ "passthroughBehavior": "WHEN_NO_TEMPLATES",
+ "responses": {
+ ".*": {
+ "statusCode": "200"
+ }
+ },
+ "timeoutInMillis": 29000,
+ "type": "AWS_PROXY",
+ "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${GET_LETTER_LAMBDA_ARN}/invocations"
+ }
+ },
"parameters": [
{
"description": "Unique identifier of this resource",
diff --git a/lambdas/api-handler/src/contracts/letters.ts b/lambdas/api-handler/src/contracts/letters.ts
index 26672c14a..d777018ac 100644
--- a/lambdas/api-handler/src/contracts/letters.ts
+++ b/lambdas/api-handler/src/contracts/letters.ts
@@ -36,7 +36,7 @@ export const PatchLetterRequestResourceSchema = z.object({
}).strict()
}).strict();
-export const PatchLetterResponseResourceSchema = z.object({
+export const GetLetterResponseResourceSchema = z.object({
id: z.string(),
type: z.literal('Letter'),
attributes: z.object({
@@ -58,12 +58,16 @@ export const GetLettersResponseResourceSchema = z.object({
}).strict()
}).strict();
+export const PatchLetterResponseResourceSchema = GetLetterResponseResourceSchema;
+
export type LetterStatus = z.infer;
export const PatchLetterRequestSchema = makeDocumentSchema(PatchLetterRequestResourceSchema);
-export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema);
+export const GetLetterResponseSchema = makeDocumentSchema(GetLetterResponseResourceSchema);
export const GetLettersResponseSchema = makeCollectionSchema(GetLettersResponseResourceSchema);
+export const PatchLetterResponseSchema = makeDocumentSchema(PatchLetterResponseResourceSchema);
export type PatchLetterRequest = z.infer;
-export type PatchLetterResponse = z.infer;
+export type GetLetterResponse = z.infer;
export type GetLettersResponse = z.infer;
+export type PatchLetterResponse = z.infer;
diff --git a/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
new file mode 100644
index 000000000..99b01b743
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/__tests__/get-letter.test.ts
@@ -0,0 +1,169 @@
+import { Context } from 'aws-lambda';
+import { mockDeep } from 'jest-mock-extended';
+import * as letterService from '../../services/letter-operations';
+import { makeApiGwEvent } from './utils/test-utils';
+import { ApiErrorDetail } from '../../contracts/errors';
+import { NotFoundError } from '../../errors';
+import { S3Client } from '@aws-sdk/client-s3';
+import pino from 'pino';
+import { LetterRepository } from '../../../../../internal/datastore/src';
+import { Deps } from '../../config/deps';
+import { EnvVars } from '../../config/env';
+import { createGetLetterHandler } from '../get-letter';
+
+jest.mock('../../services/letter-operations');
+
+
+describe('API Lambda handler', () => {
+
+ const mockedDeps: jest.Mocked = {
+ s3Client: {} as unknown as S3Client,
+ letterRepo: {} as unknown as LetterRepository,
+ logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
+ env: {
+ SUPPLIER_ID_HEADER: 'nhsd-supplier-id',
+ APIM_CORRELATION_HEADER: 'nhsd-correlation-id',
+ LETTERS_TABLE_NAME: 'LETTERS_TABLE_NAME',
+ LETTER_TTL_HOURS: 12960,
+ DOWNLOAD_URL_TTL_SECONDS: 60,
+ MAX_LIMIT: 2500
+ } as unknown as EnvVars
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.resetModules();
+ });
+
+ it('returns 200 OK and the letter status', async () => {
+
+ const mockedGetLetterById = letterService.getLetterById as jest.Mock;
+ mockedGetLetterById.mockResolvedValue({
+ id: 'id1',
+ specificationId: 'spec1',
+ groupId: 'group1',
+ status: 'PENDING'
+ });
+
+ const event = makeApiGwEvent({path: '/letters/id1',
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
+ pathParameters: {id: 'id1'}});
+
+ const getLetter = createGetLetterHandler(mockedDeps);
+ const result = await getLetter(event, mockDeep(), jest.fn());
+
+ const expected = {
+ data: {
+ id: 'id1',
+ type: 'Letter',
+ attributes: {
+ status: 'PENDING',
+ specificationId: 'spec1',
+ groupId: 'group1'
+ }
+ }
+ };
+
+ expect(result).toEqual({
+ statusCode: 200,
+ body: JSON.stringify(expected, null, 2),
+ });
+ });
+
+ it('includes the reason code and reason text if present', async () => {
+
+ const mockedGetLetterById = letterService.getLetterById as jest.Mock;
+ mockedGetLetterById.mockResolvedValue({
+ id: 'id1',
+ specificationId: 'spec1',
+ groupId: 'group1',
+ status: 'FAILED',
+ reasonCode: 100,
+ reasonText: 'failed validation'
+ });
+
+ const event = makeApiGwEvent({path: '/letters/id1',
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
+ pathParameters: {id: 'id1'}});
+
+ const getLetter = createGetLetterHandler(mockedDeps);
+ const result = await getLetter(event, mockDeep(), jest.fn());
+
+ const expected = {
+ data: {
+ id: 'id1',
+ type: 'Letter',
+ attributes: {
+ status: 'FAILED',
+ specificationId: 'spec1',
+ groupId: 'group1',
+ reasonCode: 100,
+ reasonText: 'failed validation'
+ }
+ }
+ };
+
+ expect(result).toEqual({
+ statusCode: 200,
+ body: JSON.stringify(expected, null, 2),
+ });
+ });
+
+ it('returns 404 Not Found when letter matching id is not found', async () => {
+
+ const mockedGetLetterById = letterService.getLetterById as jest.Mock;
+ mockedGetLetterById.mockImplementation(() => {
+ throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
+ });
+
+ const event = makeApiGwEvent({path: '/letters/id1',
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
+ pathParameters: {id: 'id1'}});
+
+ const getLetter = createGetLetterHandler(mockedDeps);
+ const result = await getLetter(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 404,
+ }));
+ });
+
+ it ('returns 500 when correlation id is missing from header', async() => {
+ const event = makeApiGwEvent({path: '/letters/id1',
+ headers: {'nhsd-supplier-id': 'supplier1', 'x-request-id': 'requestId'},
+ pathParameters: {id: 'id1'}});
+
+ const getLetter = createGetLetterHandler(mockedDeps);
+ const result = await getLetter(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 500,
+ }));
+ });
+
+ it ('returns 500 when supplier id is missing from header', async() => {
+ const event = makeApiGwEvent({path: '/letters/id1',
+ headers: {'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'},
+ pathParameters: {id: 'id1'}});
+
+ const getLetter = createGetLetterHandler(mockedDeps);
+ const result = await getLetter(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 500,
+ }));
+ });
+
+
+ it ('returns 400 when letter id is missing from path', async() => {
+ const event = makeApiGwEvent({path: '/letters/id1',
+ headers: {'nhsd-supplier-id': 'supplier1', 'nhsd-correlation-id': 'correlationId', 'x-request-id': 'requestId'}});
+
+ const getLetter = createGetLetterHandler(mockedDeps);
+ const result = await getLetter(event, mockDeep(), jest.fn());
+
+ expect(result).toEqual(expect.objectContaining({
+ statusCode: 400,
+ }));
+ });
+});
diff --git a/lambdas/api-handler/src/handlers/get-letter.ts b/lambdas/api-handler/src/handlers/get-letter.ts
new file mode 100644
index 000000000..5c428dcef
--- /dev/null
+++ b/lambdas/api-handler/src/handlers/get-letter.ts
@@ -0,0 +1,43 @@
+import { APIGatewayProxyHandler } from "aws-lambda";
+import { assertNotEmpty, validateCommonHeaders } from "../utils/validation";
+import { ValidationError } from "../errors";
+import { ApiErrorDetail } from "../contracts/errors";
+import { getLetterById } from "../services/letter-operations";
+import { mapErrorToResponse } from "../mappers/error-mapper";
+import { mapToGetLetterResponse } from "../mappers/letter-mapper";
+import { Deps } from "../config/deps";
+
+
+export function createGetLetterHandler(deps: Deps): APIGatewayProxyHandler {
+
+ return async (event) => {
+
+ const commonHeadersResult = validateCommonHeaders(event.headers, deps);
+
+ if (!commonHeadersResult.ok) {
+ return mapErrorToResponse(commonHeadersResult.error, commonHeadersResult.correlationId, deps.logger);
+ }
+
+ try {
+ const letterId = assertNotEmpty(event.pathParameters?.id, new ValidationError(ApiErrorDetail.InvalidRequestMissingLetterIdPathParameter));
+
+ const letter = await getLetterById(commonHeadersResult.value.supplierId, letterId, deps.letterRepo);
+
+ const response = mapToGetLetterResponse(letter);
+
+ deps.logger.info({
+ description: 'Letter successfully fetched by id',
+ supplierId: commonHeadersResult.value.supplierId,
+ letterId
+ });
+
+ return {
+ statusCode: 200,
+ body: JSON.stringify(response, null, 2),
+ };
+ } catch (error)
+ {
+ return mapErrorToResponse(error, commonHeadersResult.value.correlationId, deps.logger);
+ }
+ }
+}
diff --git a/lambdas/api-handler/src/index.ts b/lambdas/api-handler/src/index.ts
index 49a14008a..bca91bc92 100644
--- a/lambdas/api-handler/src/index.ts
+++ b/lambdas/api-handler/src/index.ts
@@ -1,10 +1,12 @@
import { createDependenciesContainer } from "./config/deps";
+import { createGetLetterHandler } from "./handlers/get-letter";
import { createGetLetterDataHandler } from "./handlers/get-letter-data";
import { createGetLettersHandler } from "./handlers/get-letters";
import { createPatchLetterHandler } from "./handlers/patch-letter";
const container = createDependenciesContainer();
+export const getLetter = createGetLetterHandler(container);
export const getLetterData = createGetLetterDataHandler(container);
export const getLetters = createGetLettersHandler(container);
export const patchLetter = createPatchLetterHandler(container);
diff --git a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts
index 96548cda8..b63825dc8 100644
--- a/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts
+++ b/lambdas/api-handler/src/mappers/__tests__/letter-mapper.test.ts
@@ -1,6 +1,6 @@
-import { mapToGetLettersResponse, mapToPatchLetterResponse } from '../letter-mapper';
+import { mapToGetLetterResponse, mapToGetLettersResponse, mapToPatchLetterResponse } from '../letter-mapper';
import { Letter } from '../../../../../internal/datastore';
-import { GetLettersResponse, PatchLetterResponse } from '../../contracts/letters';
+import { GetLetterResponse, GetLettersResponse, PatchLetterResponse } from '../../contracts/letters';
describe('letter-mapper', () => {
it('maps an internal Letter to a PatchLetterResponse', () => {
@@ -67,6 +67,71 @@ describe('letter-mapper', () => {
});
});
+
+ it('maps an internal Letter to a GetLetterResponse', () => {
+ const letter: Letter = {
+ id: 'abc123',
+ status: 'PENDING',
+ supplierId: 'supplier1',
+ specificationId: 'spec123',
+ groupId: 'group123',
+ url: 'https://example.com/letter/abc123',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ supplierStatus: 'supplier1#PENDING',
+ supplierStatusSk: Date.now().toString(),
+ ttl: 123
+ };
+
+ const result: GetLetterResponse = mapToGetLetterResponse(letter);
+
+ expect(result).toEqual({
+ data: {
+ id: 'abc123',
+ type: 'Letter',
+ attributes: {
+ specificationId: 'spec123',
+ status: 'PENDING',
+ groupId: 'group123'
+ }
+ }
+ });
+ });
+
+ it('maps an internal Letter to a GetLetterResponse with reasonCode and reasonText when present', () => {
+ const letter: Letter = {
+ id: 'abc123',
+ status: 'PENDING',
+ supplierId: 'supplier1',
+ specificationId: 'spec123',
+ groupId: 'group123',
+ url: 'https://example.com/letter/abc123',
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ supplierStatus: 'supplier1#PENDING',
+ supplierStatusSk: Date.now().toString(),
+ ttl: 123,
+ reasonCode: 123,
+ reasonText: 'Reason text'
+ };
+
+ const result: GetLetterResponse = mapToGetLetterResponse(letter);
+
+ expect(result).toEqual({
+ data: {
+ id: 'abc123',
+ type: 'Letter',
+ attributes: {
+ specificationId: 'spec123',
+ status: 'PENDING',
+ groupId: 'group123',
+ reasonCode: 123,
+ reasonText: 'Reason text',
+ }
+ }
+ });
+ });
+
it('maps an internal Letter collection to a GetLettersResponse', () => {
const letter: Letter = {
id: 'abc123',
diff --git a/lambdas/api-handler/src/mappers/letter-mapper.ts b/lambdas/api-handler/src/mappers/letter-mapper.ts
index e35bcb00c..971326fa0 100644
--- a/lambdas/api-handler/src/mappers/letter-mapper.ts
+++ b/lambdas/api-handler/src/mappers/letter-mapper.ts
@@ -1,5 +1,5 @@
import { LetterBase, LetterStatus } from "../../../../internal/datastore";
-import { GetLettersResponse, GetLettersResponseSchema, LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema } from '../contracts/letters';
+import { GetLetterResponse, GetLetterResponseSchema, GetLettersResponse, GetLettersResponseSchema, LetterDto, PatchLetterRequest, PatchLetterResponse, PatchLetterResponseSchema } from '../contracts/letters';
export function mapToLetterDto(request: PatchLetterRequest, supplierId: string) : LetterDto {
return {
@@ -13,27 +13,37 @@ export function mapToLetterDto(request: PatchLetterRequest, supplierId: string)
export function mapToPatchLetterResponse(letter: LetterBase): PatchLetterResponse {
return PatchLetterResponseSchema.parse({
- data: {
- id: letter.id,
- type: 'Letter',
- attributes: {
- status: letter.status,
- specificationId: letter.specificationId,
- groupId: letter.groupId,
- ...(letter.reasonCode != null && { reasonCode: letter.reasonCode }),
- ...(letter.reasonText != null && { reasonText: letter.reasonText })
- }
- }
+ data: letterToResourceResponse(letter)
});
}
export function mapToGetLettersResponse(letters: LetterBase[]): GetLettersResponse {
return GetLettersResponseSchema.parse({
- data: letters.map(letterToResourceResponse)
+ data: letters.map(letterToGetLettersResourceResponse)
+ });
+}
+
+export function mapToGetLetterResponse(letter: LetterBase): GetLetterResponse {
+ return GetLetterResponseSchema.parse({
+ data:letterToResourceResponse(letter)
});
}
function letterToResourceResponse(letter: LetterBase) {
+ return {
+ id: letter.id,
+ type: 'Letter',
+ attributes: {
+ status: letter.status,
+ specificationId: letter.specificationId,
+ groupId: letter.groupId,
+ ...(letter.reasonCode != null && { reasonCode: letter.reasonCode }),
+ ...(letter.reasonText != null && { reasonText: letter.reasonText })
+ }
+ };
+};
+
+function letterToGetLettersResourceResponse(letter: LetterBase) {
return {
id: letter.id,
type: 'Letter',
@@ -43,4 +53,4 @@ function letterToResourceResponse(letter: LetterBase) {
groupId: letter.groupId
}
};
-}
+};
diff --git a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
index b8338e84d..7c24c9582 100644
--- a/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
+++ b/lambdas/api-handler/src/services/__tests__/letter-operations.test.ts
@@ -1,7 +1,7 @@
import { Letter, LetterRepository } from '../../../../../internal/datastore/src';
import { Deps } from '../../config/deps';
-import { LetterDto } from '../../contracts/letters';
-import { getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations';
+import { LetterDto, LetterStatus } from '../../contracts/letters';
+import { getLetterById, getLetterDataUrl, getLettersForSupplier, patchLetterStatus } from '../letter-operations';
import pino from 'pino';
jest.mock('@aws-sdk/s3-request-presigner', () => ({
@@ -50,6 +50,48 @@ describe("getLetterIdsForSupplier", () => {
});
});
+describe("getLetterById", () => {
+
+ const testLetter = { id: "id1", status: "PENDING", specificationId: "s1", groupId: "g1", };
+
+ it("returns letter from the repository", async () => {
+
+ const mockRepo = {
+ getLetterById: jest.fn().mockResolvedValue(testLetter),
+ };
+
+ const result = await getLetterById(
+ "supplier1",
+ "id1",
+ mockRepo as any,
+ );
+
+ expect(mockRepo.getLetterById).toHaveBeenCalledWith(
+ "supplier1",
+ "id1",
+ );
+ expect(result).toEqual({ id: 'id1', status: 'PENDING', specificationId: 's1', groupId: 'g1' });
+ });
+
+ it('should throw notFoundError when letter does not exist', async () => {
+ const mockRepo = {
+ getLetterById: jest.fn().mockRejectedValue(new Error('Letter with id l1 not found for supplier s1'))
+ };
+
+ await expect(getLetterById('supplierid', 'letter1', mockRepo as any)).rejects.toThrow('No resource found with that ID');
+ });
+
+ it('should throw unexpected error', async () => {
+
+ const mockRepo = {
+ getLetterById: jest.fn().mockRejectedValue(new Error('unexpected error'))
+ };
+
+ await expect(getLetterById('supplierid', 'letter1', mockRepo as any)).rejects.toThrow("unexpected error");
+ });
+
+});
+
describe('patchLetterStatus function', () => {
beforeEach(() => {
diff --git a/lambdas/api-handler/src/services/letter-operations.ts b/lambdas/api-handler/src/services/letter-operations.ts
index f64b72f9f..6249e86a1 100644
--- a/lambdas/api-handler/src/services/letter-operations.ts
+++ b/lambdas/api-handler/src/services/letter-operations.ts
@@ -13,6 +13,22 @@ export const getLettersForSupplier = async (supplierId: string, status: string,
return await letterRepo.getLettersBySupplier(supplierId, status, limit);
}
+export const getLetterById = async (supplierId: string, letterId: string, letterRepo: LetterRepository): Promise => {
+
+ let letter;
+
+ try {
+ letter = await letterRepo.getLetterById(supplierId, letterId);
+ } catch (error) {
+ if (isNotFoundError(error)) {
+ throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
+ }
+ throw error;
+ }
+
+ return letter;
+}
+
export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: string, letterRepo: LetterRepository): Promise => {
if (letterToUpdate.id !== letterId) {
@@ -24,7 +40,7 @@ export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: str
try {
updatedLetter = await letterRepo.updateLetterStatus(letterToUpdate);
} catch (error) {
- if (error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message)) {
+ if (isNotFoundError(error)) {
throw new NotFoundError(ApiErrorDetail.NotFoundLetterId);
}
throw error;
@@ -32,6 +48,9 @@ export const patchLetterStatus = async (letterToUpdate: LetterDto, letterId: str
return mapToPatchLetterResponse(updatedLetter);
}
+function isNotFoundError(error: any) {
+ return error instanceof Error && /^Letter with id \w+ not found for supplier \w+$/.test(error.message);
+}
export const getLetterDataUrl = async (supplierId: string, letterId: string, deps: Deps): Promise => {
diff --git a/server/_config.version.yml b/server/_config.version.yml
index 6660203eb..d03844ea3 100644
--- a/server/_config.version.yml
+++ b/server/_config.version.yml
@@ -1 +1 @@
-version: 0.2.0-20250724.124925+dcf6731
+version: 0.2.0-20251010.134120+05606d5
diff --git a/src/server/_config.version.yml b/src/server/_config.version.yml
index b092a70a1..591e87e24 100644
--- a/src/server/_config.version.yml
+++ b/src/server/_config.version.yml
@@ -1 +1 @@
-version: 0.2.0-20250724.105048+579bd65
+version: 0.2.0-20251010.134144+05606d5