Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions internal/datastore/src/__test__/letter-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,7 @@ describe("LetterRepository", () => {
await checkLetterStatus("supplier1", "letter59", "PENDING");
});

// eslint-disable-next-line jest/expect-expect
test("should skip array gaps", async () => {
const letters = [];
letters[0] = createLetter("supplier1", "letter1");
Expand All @@ -516,4 +517,71 @@ describe("LetterRepository", () => {
]),
).rejects.toThrow("Cannot do operations on a non-existent table");
});

test("should paginate through multiple pages when fetching letters by supplier", async () => {
const mockSend = jest
.fn()
// first call returns 30 items with a LastEvaluatedKey
.mockResolvedValueOnce({
Items: Array.from({ length: 30 }, (_, i) => ({
id: `letter${(i + 1).toString().padStart(3, "0")}`,
status: "PENDING",
specificationId: "specification1",
groupId: "group1",
})),
LastEvaluatedKey: { id: "letter030", supplierId: "supplier1" },
})
// second call returns remaining 20 items without LastEvaluatedKey
.mockResolvedValueOnce({
Items: Array.from({ length: 20 }, (_, i) => ({
id: `letter${(i + 31).toString().padStart(3, "0")}`,
status: "PENDING",
specificationId: "specification1",
groupId: "group1",
})),
LastEvaluatedKey: undefined,
});

const mockDdbClient = { send: mockSend } as any;
const repo = new LetterRepository(mockDdbClient, logger, db.config);

// request 50 letters - should require 2 DynamoDB queries due to mocked pagination
const letters = await repo.getLettersBySupplier("supplier1", "PENDING", 50);

// verify all 50 letters were returned
expect(letters).toHaveLength(50);

// verify two send calls were made (2 pages)
expect(mockSend).toHaveBeenCalledTimes(2);

// verify the second call included the ExclusiveStartKey from first response
const secondCallInput = mockSend.mock.calls[1][0].input;
expect(secondCallInput.ExclusiveStartKey).toEqual({
id: "letter030",
supplierId: "supplier1",
});
});

test("should respect limit when fewer items available than requested", async () => {
// create only 10 letters
for (let i = 1; i <= 10; i++) {
await letterRepository.putLetter(
createLetter(
"supplier1",
`letter${i.toString().padStart(2, "0")}`,
"PENDING",
),
);
}

// request 50 letters but only 10 exist
const letters = await letterRepository.getLettersBySupplier(
"supplier1",
"PENDING",
50,
);

expect(letters).toHaveLength(10);
expect(letters.every((l) => l.status === "PENDING")).toBe(true);
});
});
50 changes: 33 additions & 17 deletions internal/datastore/src/letter-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
async unsafePutLetterBatch(letters: InsertLetter[]): Promise<void> {
let lettersDb: Letter[] = [];
for (let i = 0; i < letters.length; i++) {
const letter = letters[i];

Check warning on line 76 in internal/datastore/src/letter-repository.ts

View workflow job for this annotation

GitHub Actions / Test stage / Linting

Variable Assigned to Object Injection Sink

if (letter) {
lettersDb.push({
Expand Down Expand Up @@ -230,23 +230,39 @@
status: string,
limit: number,
): Promise<LetterBase[]> {
const items: Record<string, any>[] = [];
let ExclusiveStartKey: Record<string, any> | undefined;
const supplierStatus = `${supplierId}#${status}`;
const result = await this.ddbClient.send(
new QueryCommand({
TableName: this.config.lettersTableName,
IndexName: "supplierStatus-index",
KeyConditionExpression: "supplierStatus = :supplierStatus",
Limit: limit,
ExpressionAttributeNames: {
"#status": "status", // reserved keyword
},
ExpressionAttributeValues: {
":supplierStatus": supplierStatus,
},
ProjectionExpression:
"id, #status, specificationId, groupId, reasonCode, reasonText",
}),
);
return z.array(LetterSchemaBase).parse(result.Items ?? []);
let res;

do {
const remaining = limit - items.length;

res = await this.ddbClient.send(
new QueryCommand({
TableName: this.config.lettersTableName,
IndexName: "supplierStatus-index",
KeyConditionExpression: "supplierStatus = :supplierStatus",
ExpressionAttributeNames: {
"#status": "status", // reserved keyword
},
ExpressionAttributeValues: {
":supplierStatus": supplierStatus,
},
ProjectionExpression:
"id, #status, specificationId, groupId, reasonCode, reasonText",
Limit: remaining, // limit is a per-page cap
ExclusiveStartKey,
}),
);

if (res.Items?.length) {
items.push(...res.Items);
}

ExclusiveStartKey = res.LastEvaluatedKey;
} while (res.LastEvaluatedKey && items.length < limit);

return z.array(LetterSchemaBase).parse(items);
}
}
Loading