Skip to content

Commit 7773268

Browse files
Merge branch 'main' into CCM-13930_EnableLetterBackups
2 parents 05b5349 + a83ff2e commit 7773268

17 files changed

Lines changed: 325 additions & 74 deletions

File tree

docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"debug": "JEKYLL_ENV=development BUNDLE_GEMFILE=Gemfile bundle exec jekyll serve --config _config.yml,_config.dev.yml,_config.version.yml --limit_posts 100 --trace",
1616
"generate-includes": "./generate-includes.sh",
1717
"lint": "echo \"Documentation module has no code to lint\"",
18+
"lint:fix": "echo \"Documentation module has no code to lint\"",
1819
"test:unit": "echo \"Documentation module has no unit tests\"",
1920
"typecheck": "echo \"Documentation module has no typescript to typecheck\""
2021
},

infrastructure/terraform/components/api/module_lambda_letter_updates_transformer.tf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@ module "letter_updates_transformer" {
3535
log_subscription_role_arn = local.acct.log_subscription_role_arn
3636

3737
lambda_env_vars = merge(local.common_lambda_env_vars, {
38-
EVENTPUB_SNS_TOPIC_ARN = "${module.eventpub.sns_topic.arn}"
38+
EVENTPUB_SNS_TOPIC_ARN = "${module.eventpub.sns_topic.arn}",
39+
EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
3940
})
4041
}
4142

internal/datastore/src/__test__/letter-repository.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,7 @@ describe("LetterRepository", () => {
494494
await checkLetterStatus("supplier1", "letter59", "PENDING");
495495
});
496496

497+
// eslint-disable-next-line jest/expect-expect
497498
test("should skip array gaps", async () => {
498499
const letters = [];
499500
letters[0] = createLetter("supplier1", "letter1");
@@ -516,4 +517,71 @@ describe("LetterRepository", () => {
516517
]),
517518
).rejects.toThrow("Cannot do operations on a non-existent table");
518519
});
520+
521+
test("should paginate through multiple pages when fetching letters by supplier", async () => {
522+
const mockSend = jest
523+
.fn()
524+
// first call returns 30 items with a LastEvaluatedKey
525+
.mockResolvedValueOnce({
526+
Items: Array.from({ length: 30 }, (_, i) => ({
527+
id: `letter${(i + 1).toString().padStart(3, "0")}`,
528+
status: "PENDING",
529+
specificationId: "specification1",
530+
groupId: "group1",
531+
})),
532+
LastEvaluatedKey: { id: "letter030", supplierId: "supplier1" },
533+
})
534+
// second call returns remaining 20 items without LastEvaluatedKey
535+
.mockResolvedValueOnce({
536+
Items: Array.from({ length: 20 }, (_, i) => ({
537+
id: `letter${(i + 31).toString().padStart(3, "0")}`,
538+
status: "PENDING",
539+
specificationId: "specification1",
540+
groupId: "group1",
541+
})),
542+
LastEvaluatedKey: undefined,
543+
});
544+
545+
const mockDdbClient = { send: mockSend } as any;
546+
const repo = new LetterRepository(mockDdbClient, logger, db.config);
547+
548+
// request 50 letters - should require 2 DynamoDB queries due to mocked pagination
549+
const letters = await repo.getLettersBySupplier("supplier1", "PENDING", 50);
550+
551+
// verify all 50 letters were returned
552+
expect(letters).toHaveLength(50);
553+
554+
// verify two send calls were made (2 pages)
555+
expect(mockSend).toHaveBeenCalledTimes(2);
556+
557+
// verify the second call included the ExclusiveStartKey from first response
558+
const secondCallInput = mockSend.mock.calls[1][0].input;
559+
expect(secondCallInput.ExclusiveStartKey).toEqual({
560+
id: "letter030",
561+
supplierId: "supplier1",
562+
});
563+
});
564+
565+
test("should respect limit when fewer items available than requested", async () => {
566+
// create only 10 letters
567+
for (let i = 1; i <= 10; i++) {
568+
await letterRepository.putLetter(
569+
createLetter(
570+
"supplier1",
571+
`letter${i.toString().padStart(2, "0")}`,
572+
"PENDING",
573+
),
574+
);
575+
}
576+
577+
// request 50 letters but only 10 exist
578+
const letters = await letterRepository.getLettersBySupplier(
579+
"supplier1",
580+
"PENDING",
581+
50,
582+
);
583+
584+
expect(letters).toHaveLength(10);
585+
expect(letters.every((l) => l.status === "PENDING")).toBe(true);
586+
});
519587
});

internal/datastore/src/letter-repository.ts

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -230,23 +230,39 @@ export class LetterRepository {
230230
status: string,
231231
limit: number,
232232
): Promise<LetterBase[]> {
233+
const items: Record<string, any>[] = [];
234+
let ExclusiveStartKey: Record<string, any> | undefined;
233235
const supplierStatus = `${supplierId}#${status}`;
234-
const result = await this.ddbClient.send(
235-
new QueryCommand({
236-
TableName: this.config.lettersTableName,
237-
IndexName: "supplierStatus-index",
238-
KeyConditionExpression: "supplierStatus = :supplierStatus",
239-
Limit: limit,
240-
ExpressionAttributeNames: {
241-
"#status": "status", // reserved keyword
242-
},
243-
ExpressionAttributeValues: {
244-
":supplierStatus": supplierStatus,
245-
},
246-
ProjectionExpression:
247-
"id, #status, specificationId, groupId, reasonCode, reasonText",
248-
}),
249-
);
250-
return z.array(LetterSchemaBase).parse(result.Items ?? []);
236+
let res;
237+
238+
do {
239+
const remaining = limit - items.length;
240+
241+
res = await this.ddbClient.send(
242+
new QueryCommand({
243+
TableName: this.config.lettersTableName,
244+
IndexName: "supplierStatus-index",
245+
KeyConditionExpression: "supplierStatus = :supplierStatus",
246+
ExpressionAttributeNames: {
247+
"#status": "status", // reserved keyword
248+
},
249+
ExpressionAttributeValues: {
250+
":supplierStatus": supplierStatus,
251+
},
252+
ProjectionExpression:
253+
"id, #status, specificationId, groupId, reasonCode, reasonText",
254+
Limit: remaining, // limit is a per-page cap
255+
ExclusiveStartKey,
256+
}),
257+
);
258+
259+
if (res.Items?.length) {
260+
items.push(...res.Items);
261+
}
262+
263+
ExclusiveStartKey = res.LastEvaluatedKey;
264+
} while (res.LastEvaluatedKey && items.length < limit);
265+
266+
return z.array(LetterSchemaBase).parse(items);
251267
}
252268
}

internal/events/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,5 +50,5 @@
5050
"typecheck": "tsc --noEmit"
5151
},
5252
"types": "dist/index.d.ts",
53-
"version": "1.0.8"
53+
"version": "1.0.9"
5454
}

internal/events/schemas/examples/letter.ACCEPTED.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
"recordedtime": "2025-08-28T08:45:00.000Z",
1919
"severitynumber": 2,
2020
"severitytext": "INFO",
21-
"source": "/data-plane/supplier-api/prod/update-status",
21+
"source": "/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status",
2222
"specversion": "1.0",
2323
"subject": "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479",
2424
"time": "2025-08-28T08:45:00.000Z",

internal/events/schemas/examples/letter.FORWARDED.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"recordedtime": "2025-08-28T08:45:00.000Z",
2121
"severitynumber": 2,
2222
"severitytext": "INFO",
23-
"source": "/data-plane/supplier-api/prod/update-status",
23+
"source": "/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status",
2424
"specversion": "1.0",
2525
"subject": "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479",
2626
"time": "2025-08-28T08:45:00.000Z",

internal/events/schemas/examples/letter.RETURNED.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
"recordedtime": "2025-08-28T08:45:00.000Z",
2121
"severitynumber": 2,
2222
"severitytext": "INFO",
23-
"source": "/data-plane/supplier-api/prod/update-status",
23+
"source": "/data-plane/supplier-api/nhs-supplier-api-prod/main/update-status",
2424
"specversion": "1.0",
2525
"subject": "letter-origin/letter-rendering/letter/f47ac10b-58cc-4372-a567-0e02b2c3d479",
2626
"time": "2025-08-28T08:45:00.000Z",

lambdas/letter-updates-transformer/src/__tests__/letter-updates-transformer.test.ts

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,18 @@ jest.mock("crypto", () => ({
2626
randomBytes: (size: number) => randomBytes[String(size)],
2727
}));
2828

29-
describe("letter-updates-transformer Lambda", () => {
30-
const mockedDeps: jest.Mocked<Deps> = {
31-
snsClient: { send: jest.fn() } as unknown as SNSClient,
32-
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
33-
env: {
34-
EVENTPUB_SNS_TOPIC_ARN: "arn:aws:sns:region:account:topic",
35-
} as unknown as EnvVars,
36-
} as Deps;
29+
const eventSource =
30+
"/data-plane/supplier-api/nhs-supplier-api-dev/main/letters";
31+
const mockedDeps: jest.Mocked<Deps> = {
32+
snsClient: { send: jest.fn() } as unknown as SNSClient,
33+
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
34+
env: {
35+
EVENTPUB_SNS_TOPIC_ARN: "arn:aws:sns:region:account:topic",
36+
EVENT_SOURCE: eventSource,
37+
} as unknown as EnvVars,
38+
} as Deps;
3739

40+
describe("letter-updates-transformer Lambda", () => {
3841
beforeEach(() => {
3942
jest.useFakeTimers();
4043
});
@@ -50,7 +53,9 @@ describe("letter-updates-transformer Lambda", () => {
5053
const newLetter = generateLetter("PRINTED");
5154
const expectedEntries = [
5255
expect.objectContaining({
53-
Message: JSON.stringify(mapLetterToCloudEvent(newLetter)),
56+
Message: JSON.stringify(
57+
mapLetterToCloudEvent(newLetter, eventSource),
58+
),
5459
}),
5560
];
5661

@@ -76,7 +81,9 @@ describe("letter-updates-transformer Lambda", () => {
7681
newLetter.reasonCode = "R1";
7782
const expectedEntries = [
7883
expect.objectContaining({
79-
Message: JSON.stringify(mapLetterToCloudEvent(newLetter)),
84+
Message: JSON.stringify(
85+
mapLetterToCloudEvent(newLetter, eventSource),
86+
),
8087
}),
8188
];
8289

@@ -103,7 +110,9 @@ describe("letter-updates-transformer Lambda", () => {
103110
newLetter.reasonCode = "R2";
104111
const expectedEntries = [
105112
expect.objectContaining({
106-
Message: JSON.stringify(mapLetterToCloudEvent(newLetter)),
113+
Message: JSON.stringify(
114+
mapLetterToCloudEvent(newLetter, eventSource),
115+
),
107116
}),
108117
];
109118

@@ -135,14 +144,28 @@ describe("letter-updates-transformer Lambda", () => {
135144
expect(mockedDeps.snsClient.send).not.toHaveBeenCalled();
136145
});
137146

138-
it("does not publish non-modify events", async () => {
147+
it("publishes INSERT events", async () => {
139148
const handler = createHandler(mockedDeps);
140149
const newLetter = generateLetter("ACCEPTED");
150+
const expectedEntries = [
151+
expect.objectContaining({
152+
Message: JSON.stringify(
153+
mapLetterToCloudEvent(newLetter, eventSource),
154+
),
155+
}),
156+
];
141157

142158
const testData = generateKinesisEvent([generateInsertRecord(newLetter)]);
143159
await handler(testData, mockDeep<Context>(), jest.fn());
144160

145-
expect(mockedDeps.snsClient.send).not.toHaveBeenCalled();
161+
expect(mockedDeps.snsClient.send).toHaveBeenCalledWith(
162+
expect.objectContaining({
163+
input: expect.objectContaining({
164+
TopicArn: "arn:aws:sns:region:account:topic",
165+
PublishBatchRequestEntries: expectedEntries,
166+
}),
167+
}),
168+
);
146169
});
147170

148171
it("does not publish invalid letter data", async () => {
@@ -159,6 +182,55 @@ describe("letter-updates-transformer Lambda", () => {
159182

160183
expect(mockedDeps.snsClient.send).not.toHaveBeenCalled();
161184
});
185+
186+
it("throws error when kinesis data contains malformed JSON", async () => {
187+
const handler = createHandler(mockedDeps);
188+
189+
// Create a Kinesis event with malformed JSON data
190+
const malformedKinesisEvent: KinesisStreamEvent = {
191+
Records: [
192+
{
193+
kinesis: {
194+
data: Buffer.from("invalid-json-data").toString("base64"),
195+
sequenceNumber: "12345",
196+
},
197+
} as any,
198+
],
199+
};
200+
201+
await expect(
202+
handler(malformedKinesisEvent, mockDeep<Context>(), jest.fn()),
203+
).rejects.toThrow();
204+
205+
expect(mockedDeps.logger.error).toHaveBeenCalledWith(
206+
expect.objectContaining({
207+
description: "Error extracting payload",
208+
error: expect.any(Error),
209+
record: expect.objectContaining({
210+
kinesis: expect.objectContaining({
211+
data: Buffer.from("invalid-json-data").toString("base64"),
212+
}),
213+
}),
214+
}),
215+
);
216+
});
217+
218+
it("handles events with no records", async () => {
219+
const handler = createHandler(mockedDeps);
220+
221+
// Create a Kinesis event with empty Records array
222+
const emptyKinesisEvent: KinesisStreamEvent = { Records: [] };
223+
224+
await handler(emptyKinesisEvent, mockDeep<Context>(), jest.fn());
225+
226+
expect(mockedDeps.logger.info).toHaveBeenCalledWith(
227+
expect.objectContaining({
228+
description: "Number of records",
229+
count: 0,
230+
}),
231+
);
232+
expect(mockedDeps.snsClient.send).not.toHaveBeenCalled();
233+
});
162234
});
163235

164236
describe("Batching", () => {
@@ -168,7 +240,7 @@ describe("letter-updates-transformer Lambda", () => {
168240
const newLetters = generateLetters(10, "PRINTED");
169241
const expectedEntries = newLetters.map((letter) =>
170242
expect.objectContaining({
171-
Message: JSON.stringify(mapLetterToCloudEvent(letter)),
243+
Message: JSON.stringify(mapLetterToCloudEvent(letter, eventSource)),
172244
}),
173245
);
174246

@@ -197,19 +269,19 @@ describe("letter-updates-transformer Lambda", () => {
197269
newLetters.slice(0, 10).map((letter, index) =>
198270
expect.objectContaining({
199271
Id: expect.stringMatching(new RegExp(`-${index}$`)),
200-
Message: JSON.stringify(mapLetterToCloudEvent(letter)),
272+
Message: JSON.stringify(mapLetterToCloudEvent(letter, eventSource)),
201273
}),
202274
),
203275
newLetters.slice(10, 20).map((letter, index) =>
204276
expect.objectContaining({
205277
Id: expect.stringMatching(new RegExp(`-${index}$`)),
206-
Message: JSON.stringify(mapLetterToCloudEvent(letter)),
278+
Message: JSON.stringify(mapLetterToCloudEvent(letter, eventSource)),
207279
}),
208280
),
209281
newLetters.slice(20).map((letter, index) =>
210282
expect.objectContaining({
211283
Id: expect.stringMatching(new RegExp(`-${index}$`)),
212-
Message: JSON.stringify(mapLetterToCloudEvent(letter)),
284+
Message: JSON.stringify(mapLetterToCloudEvent(letter, eventSource)),
213285
}),
214286
),
215287
];

lambdas/letter-updates-transformer/src/env.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from "zod";
22

33
const EnvVarsSchema = z.object({
44
EVENTPUB_SNS_TOPIC_ARN: z.string(),
5+
EVENT_SOURCE: z.string(),
56
});
67

78
export type EnvVars = z.infer<typeof EnvVarsSchema>;

0 commit comments

Comments
 (0)