Skip to content

Commit 4e356a4

Browse files
Wire patch letter and ddb
1 parent 839ee42 commit 4e356a4

File tree

14 files changed

+340
-85
lines changed

14 files changed

+340
-85
lines changed

internal/datastore/src/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ export const SupplierSchema = z.object({
1313
export type Supplier = z.infer<typeof SupplierSchema>;
1414

1515
export const LetterStatus = z.enum([
16-
'PENDING', 'ACCEPTED', 'DISPATCHED', 'FAILED',
17-
'REJECTED', 'DELIVERED', 'CANCELLED']);
16+
'PENDING', 'ACCEPTED', 'REJECTED', 'PRINTED',
17+
'ENCLOSED', 'CANCELLED', 'DISPATCHED', 'FAILED',
18+
'RETURNED', 'DESTROYED', 'FORWARDED']);
1819

1920
export const LetterSchema = z.object({
2021
id: z.string(),
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export interface LetterApiAttributes {
2+
reasonCode: number;
3+
reasonText: string;
4+
requestedProductionStatus: 'ACTIVE' | 'HOLD' | 'CANCEL';
5+
status: LetterApiStatus;
6+
}
7+
8+
export interface LetterApiResource {
9+
id: string;
10+
type: 'Letter';
11+
attributes: LetterApiAttributes;
12+
}
13+
14+
export interface LetterApiDocument {
15+
data: LetterApiResource;
16+
}
17+
18+
export type LetterApiStatus = 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'PRINTED' | 'ENCLOSED' | 'CANCELLED' | 'DISPATCHED' | 'FAILED' | 'RETURNED' | 'DESTROYED' | 'FORWARDED';
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export class NotFoundError extends Error {}
2+
export class ValidationError extends Error {}
3+
export class ConflictError extends Error {}

lambdas/api-handler/src/handlers/__tests__/get-letters.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,9 @@ import { getLetters } from '../../index';
22
import type { Context } from 'aws-lambda';
33
import { mockDeep } from 'jest-mock-extended';
44
import { makeApiGwEvent } from './utils/test-utils';
5-
import * as letterService from '../../services/get-letter-ids'; // <- what we're mocking
5+
import * as letterService from '../../services/letter-operations';
66

7-
jest.mock('../../services/get-letter-ids');
7+
jest.mock('../../services/letter-operations');
88

99
describe('API Lambda handler', () => {
1010

lambdas/api-handler/src/handlers/__tests__/patch-letters.test.ts

Lines changed: 91 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,35 +2,49 @@ import { patchLetters } from '../../index';
22
import type { Context } from 'aws-lambda';
33
import { mockDeep } from 'jest-mock-extended';
44
import { makeApiGwEvent } from './utils/test-utils';
5+
import * as letterService from '../../services/letter-operations';
6+
import { NotFoundError, ValidationError } from '../../errors';
7+
import { LetterApiDocument, LetterApiStatus } from '../../contracts/letter-api';
58

6-
const requestBody = {
7-
"data": {
8-
"attributes": {
9-
"reasonCode": 100,
10-
"reasonText": "failed validation",
11-
"requestedProductionStatus": "ACTIVE",
12-
"status": "REJECTED"
13-
},
14-
"id": "id1",
15-
"type": "Letter"
16-
}
17-
};
18-
19-
const requestBodyString = JSON.stringify(requestBody, null, 2);
20-
21-
describe('API Lambda handler', () => {
9+
jest.mock('../../services/letter-operations');
10+
11+
function makeLetterApiDocument(id: string, status: LetterApiStatus) : LetterApiDocument {
12+
return {
13+
data: {
14+
attributes: {
15+
reasonCode: 123,
16+
reasonText: "Reason text",
17+
requestedProductionStatus: "ACTIVE",
18+
status
19+
},
20+
id,
21+
type: "Letter"
22+
}
23+
};
24+
}
25+
26+
const letterApiDocument = makeLetterApiDocument("id1", "REJECTED");
27+
28+
const requestBody = JSON.stringify(letterApiDocument, null, 2);
29+
30+
describe('patchLetters API Handler', () => {
2231
it('returns 200 OK with updated resource', async () => {
32+
2333
const event = makeApiGwEvent({
2434
path: '/letters/id1',
25-
body: requestBodyString,
35+
body: requestBody,
2636
pathParameters: {id: "id1"}});
2737
const context = mockDeep<Context>();
2838
const callback = jest.fn();
39+
40+
const mockedPatchLetterStatus = letterService.patchLetterStatus as jest.Mock;
41+
mockedPatchLetterStatus.mockResolvedValue(letterApiDocument);
42+
2943
const result = await patchLetters(event, context, callback);
3044

3145
expect(result).toEqual({
3246
statusCode: 200,
33-
body: requestBodyString,
47+
body: requestBody,
3448
});
3549
});
3650

@@ -44,37 +58,91 @@ describe('API Lambda handler', () => {
4458

4559
expect(result).toEqual({
4660
statusCode: 400,
47-
body: 'Bad Request',
61+
body: 'Bad Request: Missing request body',
4862
});
4963
});
5064

5165
it('returns 404 Not Found as path is unknown', async () => {
5266
const event = makeApiGwEvent({
5367
path: '/unknown',
54-
body: requestBodyString,
68+
body: requestBody,
5569
pathParameters: {id: "id1"}});
5670
const context = mockDeep<Context>();
5771
const callback = jest.fn();
5872
const result = await patchLetters(event, context, callback);
5973

6074
expect(result).toEqual({
6175
statusCode: 404,
62-
body: 'Not Found',
76+
body: 'Not Found: The requested resource does not exist',
6377
});
6478
});
6579

6680
it('returns 404 Not Found as path parameter is not found', async () => {
6781
const event = makeApiGwEvent({
6882
path: '/letters',
69-
body: requestBodyString});
83+
body: requestBody});
7084
const context = mockDeep<Context>();
7185
const callback = jest.fn();
7286
const result = await patchLetters(event, context, callback);
7387

7488
expect(result).toEqual({
7589
statusCode: 404,
76-
body: 'Not Found',
90+
body: 'Not Found: The requested resource does not exist',
91+
});
92+
});
93+
94+
it('returns 400 Bad Request when ValidationError is thrown by service', async () => {
95+
const mockedPatchLetterStatus = letterService.patchLetterStatus as jest.Mock;
96+
mockedPatchLetterStatus.mockRejectedValue(new ValidationError('Validation failed'));
97+
98+
const event = makeApiGwEvent({
99+
path: '/letters/id1',
100+
body: requestBody,
101+
pathParameters: { id: "id1" }
102+
});
103+
const context = mockDeep<Context>();
104+
const callback = jest.fn();
105+
106+
const result = await patchLetters(event, context, callback);
107+
108+
expect(result).toEqual({
109+
statusCode: 400,
110+
body: 'Validation failed'
77111
});
78112
});
79113

114+
it('returns 404 Not Found when NotFoundError is thrown by service', async () => {
115+
const mockedPatchLetterStatus = letterService.patchLetterStatus as jest.Mock;
116+
mockedPatchLetterStatus.mockRejectedValue(new NotFoundError('Letter not found'));
117+
118+
const event = makeApiGwEvent({
119+
path: '/letters/id1',
120+
body: requestBody,
121+
pathParameters: { id: "id1" }
122+
});
123+
const context = mockDeep<Context>();
124+
const callback = jest.fn();
125+
126+
const result = await patchLetters(event, context, callback);
127+
128+
expect(result).toEqual({
129+
statusCode: 404,
130+
body: 'Letter not found'
131+
});
132+
});
133+
134+
it('throws unexpected errors from service', async () => {
135+
const mockedPatchLetterStatus = letterService.patchLetterStatus as jest.Mock;
136+
mockedPatchLetterStatus.mockRejectedValue(new Error('Unexpected error'));
137+
138+
const event = makeApiGwEvent({
139+
path: '/letters/id1',
140+
body: requestBody,
141+
pathParameters: { id: "id1" }
142+
});
143+
const context = mockDeep<Context>();
144+
const callback = jest.fn();
145+
146+
await expect(patchLetters(event, context, callback)).rejects.toThrow('Unexpected error');
147+
});
80148
});

lambdas/api-handler/src/handlers/get-letters.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,14 @@
1-
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2-
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
31
import { APIGatewayProxyHandler } from 'aws-lambda';
4-
import { LetterRepository } from '../../../../internal/datastore'
5-
import pino from 'pino';
6-
import { getLetterIdsForSupplier } from '../services/get-letter-ids';
2+
import { getLetterIdsForSupplier } from '../services/letter-operations';
3+
import { createLetterRepository } from '../infrastructure/letter-repo-factory';
74

8-
const ddbClient = new DynamoDBClient({});
9-
const docClient = DynamoDBDocumentClient.from(ddbClient);
10-
const log = pino();
11-
const config = {
12-
lettersTableName: process.env.LETTERS_TABLE_NAME!,
13-
ttlHours: parseInt(process.env.LETTER_TTL_HOURS!),
14-
};
15-
16-
const letterRepo = new LetterRepository(docClient, log, config);
5+
const letterRepo = createLetterRepository();
176

187
export const getLetters: APIGatewayProxyHandler = async (event) => {
198

209
if (event.path === '/letters') {
2110

11+
// default to supplier1 for now
2212
const supplierId = event.headers['nhsd-apim-apikey'] ?? "supplier1";
2313

2414
const letterIds = await getLetterIdsForSupplier(supplierId, letterRepo);

lambdas/api-handler/src/handlers/patch-letters.ts

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import { APIGatewayProxyHandler } from 'aws-lambda';
2+
import { createLetterRepository } from '../infrastructure/letter-repo-factory';
3+
import { patchLetterStatus } from '../services/letter-operations';
4+
import { LetterApiDocument } from '../contracts/letter-api';
5+
import { NotFoundError, ValidationError } from '../errors';
26

7+
const letterRepo = createLetterRepository();
38
export const patchLetters: APIGatewayProxyHandler = async (event) => {
49

10+
// TODO CCM-11188: default to supplier1 for now
11+
const supplierId = event.headers['nhsd-apim-apikey'] ?? "supplier1";
12+
513
const pathParameters = event.pathParameters || {};
614
const letterId = pathParameters["id"];
715

@@ -11,37 +19,41 @@ export const patchLetters: APIGatewayProxyHandler = async (event) => {
1119
{
1220
return {
1321
statusCode: 400,
14-
body: "Bad Request"
22+
body: "Bad Request: Missing request body"
1523
}
1624
}
1725

18-
const body: PatchLetterRequestBody = JSON.parse(event.body);
26+
const patchLetterRequest: LetterApiDocument = JSON.parse(event.body);
27+
28+
try {
29+
30+
// TODO CCM-11188: Is it worth retrieving the letter first to check if the status is different?
31+
32+
const result = await patchLetterStatus(patchLetterRequest.data, letterId, supplierId, letterRepo);
1933

20-
return {
21-
statusCode: 200,
22-
body: JSON.stringify(body, null, 2)
23-
};
34+
return {
35+
statusCode: 200,
36+
body: JSON.stringify(result, null, 2)
37+
};
38+
} catch (error) {
39+
if (error instanceof ValidationError) {
40+
return {
41+
statusCode: 400,
42+
body: error.message
43+
};
44+
} else if (error instanceof NotFoundError) {
45+
return {
46+
statusCode: 404,
47+
body: error.message
48+
};
49+
}
50+
throw error;
51+
}
2452
}
2553

54+
// TODO CCM-11188: Is this reachable with the API GW?
2655
return {
2756
statusCode: 404,
28-
body: 'Not Found',
57+
body: 'Not Found: The requested resource does not exist',
2958
};
3059
};
31-
32-
export interface LetterAttributes {
33-
reasonCode: number;
34-
reasonText: string;
35-
requestedProductionStatus: 'ACTIVE' | 'HOLD' | 'CANCEL';
36-
status: 'PENDING' | 'ACCEPTED' | 'REJECTED' | 'PRINTED' | 'ENCLOSED' | 'CANCELLED' | 'DISPATCHED' | 'FAILED' | 'RETURNED' | 'DESTROYED' | 'FORWARDED';
37-
}
38-
39-
export interface LetterData {
40-
id: string;
41-
type: 'Letter';
42-
attributes: LetterAttributes;
43-
}
44-
45-
export interface PatchLetterRequestBody {
46-
data: LetterData;
47-
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
2+
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
3+
import pino from 'pino';
4+
import { LetterRepository } from '../../../../internal/datastore';
5+
6+
const BASE_TEN = 10;
7+
8+
export function createLetterRepository(): LetterRepository {
9+
const ddbClient = new DynamoDBClient({});
10+
const docClient = DynamoDBDocumentClient.from(ddbClient);
11+
const log = pino();
12+
const config = {
13+
lettersTableName: process.env.LETTERS_TABLE_NAME!,
14+
ttlHours: parseInt(process.env.LETTER_TTL_HOURS!, BASE_TEN),
15+
};
16+
17+
return new LetterRepository(docClient, log, config);
18+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { toApiLetter } from '../letter-mapper';
2+
import { Letter } from '../../../../../internal/datastore';
3+
import { LetterApiDocument } from '../../contracts/letter-api';
4+
5+
describe('toApiLetter', () => {
6+
it('maps a Letter to LetterApiDocument', () => {
7+
const letter: Letter = {
8+
id: 'abc123',
9+
status: 'PENDING',
10+
supplierId: 'supplier1',
11+
specificationId: 'spec123',
12+
groupId: 'group123',
13+
url: 'https://example.com/letter/abc123',
14+
createdAt: new Date().toISOString(),
15+
updatedAt: new Date().toISOString(),
16+
supplierStatus: 'supplier1#PENDING',
17+
ttl: 123
18+
};
19+
20+
const result: LetterApiDocument = toApiLetter(letter);
21+
22+
expect(result).toEqual({
23+
data: {
24+
id: 'abc123',
25+
type: 'Letter',
26+
attributes: {
27+
reasonCode: 123,
28+
reasonText: 'Reason text',
29+
requestedProductionStatus: 'ACTIVE',
30+
status: 'PENDING'
31+
}
32+
}
33+
});
34+
});
35+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Letter } from "../../../../internal/datastore";
2+
import { LetterApiDocument } from '../contracts/letter-api';
3+
4+
export function toApiLetter(letter: Letter): LetterApiDocument {
5+
return {
6+
data: {
7+
id: letter.id,
8+
type: 'Letter',
9+
attributes: {
10+
reasonCode: 123, // TODO CCM-11188: map from DB if stored
11+
reasonText: 'Reason text', // TODO CCM-11188: map from DB if stored
12+
requestedProductionStatus: 'ACTIVE', // TODO CCM-11188: map from DB if stored
13+
status: letter.status
14+
}
15+
}
16+
};
17+
}

0 commit comments

Comments
 (0)