Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
23 changes: 13 additions & 10 deletions infrastructure/terraform/components/api/locals.tf
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,19 @@ locals {
destination_arn = "arn:aws:logs:${var.region}:${var.shared_infra_account_id}:destination:nhs-main-obs-firehose-logs"

common_lambda_env_vars = {
APIM_CORRELATION_HEADER = "nhsd-correlation-id",
DOWNLOAD_URL_TTL_SECONDS = 60
EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours
LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name,
MI_TABLE_NAME = aws_dynamodb_table.mi.name,
MI_TTL_HOURS = 2160 # 90 days * 24 hours
SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}",
SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name
SUPPLIER_ID_HEADER = "nhsd-supplier-id",
APIM_CORRELATION_HEADER = "nhsd-correlation-id",
DOWNLOAD_URL_TTL_SECONDS = 60
EVENT_SOURCE = "/data-plane/supplier-api/${var.group}/${var.environment}/letters"
LETTER_TTL_HOURS = 12960, # 18 months * 30 days * 24 hours
LETTER_QUEUE_TABLE_NAME = aws_dynamodb_table.letter_queue.name,
LETTER_QUEUE_TTL_HOURS = 168 # 7 days * 24 hours
LETTER_QUEUE_VISIBILITY_TIMEOUT = 300, # 5 minutes * 60 seconds
LETTERS_TABLE_NAME = aws_dynamodb_table.letters.name,
MI_TABLE_NAME = aws_dynamodb_table.mi.name,
MI_TTL_HOURS = 2160 # 90 days * 24 hours
SNS_TOPIC_ARN = "${module.eventsub.sns_topic.arn}",
SUPPLIER_CONFIG_TABLE_NAME = aws_dynamodb_table.supplier-configuration.name
SUPPLIER_ID_HEADER = "nhsd-supplier-id",
}

core_pdf_bucket_arn = "arn:aws:s3:::comms-${var.core_account_id}-eu-west-2-${var.core_environment}-api-stg-pdf-pipeline"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,12 @@ data "aws_iam_policy_document" "get_letters_lambda" {
"dynamodb:GetItem",
"dynamodb:Query",
"dynamodb:Scan",
"dynamodb:UpdateItem",
]

resources = [
aws_dynamodb_table.letters.arn,
"${aws_dynamodb_table.letters.arn}/index/supplierStatus-index"
aws_dynamodb_table.letter_queue.arn,
"${aws_dynamodb_table.letter_queue.arn}/index/queueSortOrder-index"
]
}
}
191 changes: 191 additions & 0 deletions internal/datastore/src/__test__/letter-queue-repository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ describe("LetterQueueRepository", () => {
afterEach(async () => {
await deleteTables(db);
jest.useRealTimers();
jest.restoreAllMocks();
});

afterAll(async () => {
Expand Down Expand Up @@ -149,6 +150,196 @@ describe("LetterQueueRepository", () => {
).rejects.toThrow("Cannot do operations on a non-existent table");
});
});

describe("getLetters", () => {
Comment thread
francisco-videira-nhs marked this conversation as resolved.
it("filters by supplierId", async () => {
await letterQueueRepository.putLetter(createLetter());

const letters = await letterQueueRepository.getLetters("supplier2", 1);

expect(letters).toHaveLength(0);
});

it("filters by visibilityTimestamp", async () => {
const pendingLetter = createLetter();
await letterQueueRepository.putLetter(createLetter());
await letterQueueRepository.updateVisibilityTimestamp(
pendingLetter,
new Date(Date.now() + 600_000),
);

const letters = await letterQueueRepository.getLetters("supplier1", 1);

expect(letters).toHaveLength(0);
});

it("returns letters in timestamp order", async () => {
jest.useFakeTimers().setSystemTime(new Date());
await letterQueueRepository.putLetter(
createLetter({ letterId: "first-letter" }),
);
jest.advanceTimersByTime(1);
await letterQueueRepository.putLetter(
createLetter({ letterId: "second-letter" }),
);
jest.advanceTimersByTime(1);
await letterQueueRepository.putLetter(
createLetter({ letterId: "third-letter" }),
);
jest.advanceTimersByTime(1);
await letterQueueRepository.putLetter(
createLetter({ letterId: "fourth-letter" }),
);
jest.advanceTimersByTime(1);
await letterQueueRepository.putLetter(
createLetter({ letterId: "fifth-letter" }),
);
jest.advanceTimersByTime(1);

const letters = await letterQueueRepository.getLetters("supplier1", 5);

expect(letters[0].letterId).toBe("first-letter");
expect(letters[1].letterId).toBe("second-letter");
expect(letters[2].letterId).toBe("third-letter");
expect(letters[3].letterId).toBe("fourth-letter");
expect(letters[4].letterId).toBe("fifth-letter");
Comment thread
stevebux marked this conversation as resolved.
});

it("limits results to the supplied number", async () => {
await letterQueueRepository.putLetter(
createLetter({ letterId: "first-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "second-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "third-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "fourth-letter" }),
);

const letters = await letterQueueRepository.getLetters("supplier1", 3);

expect(letters).toHaveLength(3);
expect(letters[2].letterId).toBe("third-letter");
});

it("applies the limit after filtering on supplier", async () => {
await letterQueueRepository.putLetter(
createLetter({ letterId: "first-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "second-letter", supplierId: "supplier2" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "third-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "fourth-letter" }),
);

const letters = await letterQueueRepository.getLetters("supplier1", 3);

expect(letters).toHaveLength(3);
expect(letters[2].letterId).toBe("fourth-letter");
});

it("applies the limit after filtering on visibilityTimestamp", async () => {
await letterQueueRepository.putLetter(
createLetter({ letterId: "first-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "second-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "third-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "fourth-letter" }),
);
await letterQueueRepository.updateVisibilityTimestamp(
createLetter({ letterId: "second-letter" }),
new Date(Date.now() + 600_000),
);

const letters = await letterQueueRepository.getLetters("supplier1", 3);

expect(letters).toHaveLength(3);
expect(letters[2].letterId).toBe("fourth-letter");
});

it("paginates through multiple DynamoDB pages to reach the limit", async () => {
await letterQueueRepository.putLetter(
createLetter({ letterId: "first-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "second-letter" }),
);
await letterQueueRepository.putLetter(
createLetter({ letterId: "third-letter" }),
);

const pagedRepository = new LetterQueueRepository(db.docClient, logger, {
...db.config,
queryPageSize: 1,
});

const letters = await pagedRepository.getLetters("supplier1", 3);

expect(letters).toHaveLength(3);
expect(letters[0].letterId).toBe("first-letter");
expect(letters[1].letterId).toBe("second-letter");
expect(letters[2].letterId).toBe("third-letter");
});

it("returns an empty array if no items found", async () => {
const letters = await letterQueueRepository.getLetters("supplier1", 3);

expect(letters).toHaveLength(0);
});
});

describe("updateVisibilityTimestamp", () => {
it("updates the visibilityTimestamp on an existing letter", async () => {
const pendingLetter =
await letterQueueRepository.putLetter(createLetter());

await letterQueueRepository.updateVisibilityTimestamp(
pendingLetter,
new Date("2026-03-04T13:15:45.000Z"),
);

const letter = await getLetter(db, "supplier1", "letter1");
expect(letter?.visibilityTimestamp).toBe("2026-03-04T13:15:45.000Z");
});

it("does nothing when the letter does not exist", async () => {
await letterQueueRepository.updateVisibilityTimestamp(
createLetter(),
new Date(),
);

expect(await letterExists(db, "supplier1", "letter1")).toBe(false);
});

it("rethrows errors from DynamoDB when updating the letter", async () => {
const misconfiguredRepository = new LetterQueueRepository(
db.docClient,
logger,
{
...db.config,
letterQueueTableName: "nonexistent-table",
},
);
await expect(
misconfiguredRepository.updateVisibilityTimestamp(
createLetter(),
new Date(),
),
).rejects.toThrow("Cannot do operations on a non-existent table");
});
});
});

async function getLetter(db: DBContext, supplierId: string, letterId: string) {
Expand Down
Loading
Loading