diff --git a/docs/package.json b/docs/package.json index ee685b8dd..2f2d699ba 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,7 +14,8 @@ "build": "JEKYLL_ENV=production bundle exec jekyll build --trace --config _config.yml,_config.version.yml", "debug": "JEKYLL_ENV=development BUNDLE_GEMFILE=Gemfile bundle exec jekyll serve --config _config.yml,_config.dev.yml,_config.version.yml --limit_posts 100 --trace", "generate-includes": "./generate-includes.sh", - "test:unit": "echo \"Documentation module has no unit tests\"" + "test:unit": "echo \"Documentation module has no unit tests\"", + "typecheck": "echo \"Documentation module has no typescript to typecheck\"" }, "version": "1.0.0" } diff --git a/eslint.config.mjs b/eslint.config.mjs index c5f57076f..3a55e1300 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -184,7 +184,7 @@ export default defineConfig([ rules: { 'no-relative-import-paths/no-relative-import-paths': 2 }, }, { - files: ['scripts/**'], + files: ['scripts/**', '**/__test__/**/*.ts', '**/__tests__/**/*.ts'], rules: { 'import-x/no-extraneous-dependencies': [ 'error', @@ -229,8 +229,8 @@ export default defineConfig([ 'no-await-in-loop': 0, 'no-plusplus': [2, { allowForLoopAfterthoughts: true }], 'unicorn/prefer-top-level-await': 0, - 'import-x/no-relative-packages': 1, - 'no-relative-import-paths/no-relative-import-paths': 1 + 'import-x/no-relative-packages': 0, + 'no-relative-import-paths/no-relative-import-paths': 0 }, }, ]); diff --git a/internal/datastore/jest.config.ts b/internal/datastore/jest.config.ts index 129da6a60..1fb73e6d1 100644 --- a/internal/datastore/jest.config.ts +++ b/internal/datastore/jest.config.ts @@ -1,7 +1,7 @@ -import type { Config } from 'jest'; +import type { Config } from "jest"; export const baseJestConfig: Config = { - preset: 'ts-jest', + preset: "ts-jest", // Automatically clear mock calls, instances, contexts and results before every test clearMocks: true, @@ -10,14 +10,14 @@ export const baseJestConfig: Config = { collectCoverage: true, // The directory where Jest should output its coverage files - coverageDirectory: './.reports/unit/coverage', + coverageDirectory: "./.reports/unit/coverage", // Indicates which provider should be used to instrument code for coverage - coverageProvider: 'babel', + coverageProvider: "babel", // Module name mapper to handle TypeScript path aliases moduleNameMapper: { - '^@internal/helpers$': '/../helpers/src' + "^@internal/helpers$": "/../helpers/src", }, coverageThreshold: { @@ -29,36 +29,36 @@ export const baseJestConfig: Config = { }, }, - coveragePathIgnorePatterns: ['/__tests__/'], - transform: { '^.+\\.ts$': 'ts-jest' }, - testPathIgnorePatterns: ['.build'], - testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + coveragePathIgnorePatterns: ["/__tests__/"], + transform: { "^.+\\.ts$": "ts-jest" }, + testPathIgnorePatterns: [".build"], + testMatch: ["**/?(*.)+(spec|test).[jt]s?(x)"], // Use this configuration option to add custom reporters to Jest reporters: [ - 'default', + "default", [ - 'jest-html-reporter', + "jest-html-reporter", { - pageTitle: 'Test Report', - outputPath: './.reports/unit/test-report.html', + pageTitle: "Test Report", + outputPath: "./.reports/unit/test-report.html", includeFailureMsg: true, }, ], ], // The test environment that will be used for testing - testEnvironment: 'jsdom', + testEnvironment: "jsdom", }; const utilsJestConfig = { ...baseJestConfig, - testEnvironment: 'node', + testEnvironment: "node", coveragePathIgnorePatterns: [ ...(baseJestConfig.coveragePathIgnorePatterns ?? []), - 'zod-validators.ts', + "zod-validators.ts", ], }; diff --git a/internal/datastore/package.json b/internal/datastore/package.json index 157d5cd21..4afead431 100644 --- a/internal/datastore/package.json +++ b/internal/datastore/package.json @@ -4,7 +4,8 @@ "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/helpers": "*", "pino": "^9.7.0", - "zod": "^4.1.11" + "zod": "^4.1.11", + "zod-mermaid": "^1.0.9" }, "devDependencies": { "@stylistic/eslint-plugin": "^3.1.0", @@ -19,8 +20,7 @@ "testcontainers": "^11.4.0", "ts-jest": "^29.4.0", "ts-node": "^10.9.2", - "typescript": "^5.9.3", - "zod-mermaid": "^1.0.9" + "typescript": "^5.9.3" }, "license": "MIT", "main": "src/index.ts", diff --git a/internal/datastore/src/__test__/db.ts b/internal/datastore/src/__test__/db.ts index 16951106a..f3606b928 100644 --- a/internal/datastore/src/__test__/db.ts +++ b/internal/datastore/src/__test__/db.ts @@ -1,39 +1,39 @@ -import { GenericContainer } from 'testcontainers'; +import { GenericContainer } from "testcontainers"; import { CreateTableCommand, DeleteTableCommand, DynamoDBClient, - UpdateTimeToLiveCommand -} from '@aws-sdk/client-dynamodb'; -import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; -import { DatastoreConfig } from '../config'; + UpdateTimeToLiveCommand, +} from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; +import { DatastoreConfig } from "../config"; export async function setupDynamoDBContainer() { - const container = await new GenericContainer('amazon/dynamodb-local') + const container = await new GenericContainer("amazon/dynamodb-local") .withExposedPorts(8000) .start(); const endpoint = `http://${container.getHost()}:${container.getMappedPort(8000)}`; const ddbClient = new DynamoDBClient({ - region: 'us-west-2', + region: "us-west-2", endpoint, credentials: { - accessKeyId: 'fakeMyKeyId', - secretAccessKey: 'fakeSecretAccessKey' - } + accessKeyId: "fakeMyKeyId", + secretAccessKey: "fakeSecretAccessKey", + }, }); const docClient = DynamoDBDocumentClient.from(ddbClient); - const config : DatastoreConfig = { - region: 'us-west-2', + const config: DatastoreConfig = { + region: "us-west-2", endpoint, - lettersTableName: 'letters', - miTableName: 'management-info', - suppliersTableName: 'suppliers', + lettersTableName: "letters", + miTableName: "management-info", + suppliersTableName: "suppliers", lettersTtlHours: 1, - miTtlHours: 1 + miTtlHours: 1, }; return { @@ -41,83 +41,82 @@ export async function setupDynamoDBContainer() { ddbClient, docClient, endpoint, - config + config, }; } export type DBContext = Awaited>; const createLetterTableCommand = new CreateTableCommand({ - TableName: 'letters', - BillingMode: 'PAY_PER_REQUEST', - KeySchema: [ - { AttributeName: 'supplierId', KeyType: 'HASH' }, // Partition key - { AttributeName: 'id', KeyType: 'RANGE' } // Sort key - ], - GlobalSecondaryIndexes: [ - { - IndexName: 'supplierStatus-index', - KeySchema: [ - { AttributeName: 'supplierStatus', KeyType: 'HASH' }, // Partition key for GSI - { AttributeName: 'supplierStatusSk', KeyType: 'RANGE' } // Sort key for GSI - ], - Projection: { - ProjectionType: 'ALL' - } - } - ], - AttributeDefinitions: [ - { AttributeName: 'supplierId', AttributeType: 'S' }, - { AttributeName: 'id', AttributeType: 'S' }, - { AttributeName: 'supplierStatus', AttributeType: 'S' }, - { AttributeName: 'supplierStatusSk', AttributeType: 'S' }, - ] - }); + TableName: "letters", + BillingMode: "PAY_PER_REQUEST", + KeySchema: [ + { AttributeName: "supplierId", KeyType: "HASH" }, // Partition key + { AttributeName: "id", KeyType: "RANGE" }, // Sort key + ], + GlobalSecondaryIndexes: [ + { + IndexName: "supplierStatus-index", + KeySchema: [ + { AttributeName: "supplierStatus", KeyType: "HASH" }, // Partition key for GSI + { AttributeName: "supplierStatusSk", KeyType: "RANGE" }, // Sort key for GSI + ], + Projection: { + ProjectionType: "ALL", + }, + }, + ], + AttributeDefinitions: [ + { AttributeName: "supplierId", AttributeType: "S" }, + { AttributeName: "id", AttributeType: "S" }, + { AttributeName: "supplierStatus", AttributeType: "S" }, + { AttributeName: "supplierStatusSk", AttributeType: "S" }, + ], +}); const updateTimeToLiveCommand = new UpdateTimeToLiveCommand({ - TableName: 'letters', - TimeToLiveSpecification: { - AttributeName: 'ttl', - Enabled: true - } - }); + TableName: "letters", + TimeToLiveSpecification: { + AttributeName: "ttl", + Enabled: true, + }, +}); const createMITableCommand = new CreateTableCommand({ - TableName: 'management-info', - BillingMode: 'PAY_PER_REQUEST', - KeySchema: [ - { AttributeName: 'supplierId', KeyType: 'HASH' }, // Partition key - { AttributeName: 'id', KeyType: 'RANGE' } // Sort key - ], - AttributeDefinitions: [ - { AttributeName: 'supplierId', AttributeType: 'S' }, - { AttributeName: 'id', AttributeType: 'S' }, - ] - }); + TableName: "management-info", + BillingMode: "PAY_PER_REQUEST", + KeySchema: [ + { AttributeName: "supplierId", KeyType: "HASH" }, // Partition key + { AttributeName: "id", KeyType: "RANGE" }, // Sort key + ], + AttributeDefinitions: [ + { AttributeName: "supplierId", AttributeType: "S" }, + { AttributeName: "id", AttributeType: "S" }, + ], +}); const createSupplierTableCommand = new CreateTableCommand({ - TableName: 'suppliers', - BillingMode: 'PAY_PER_REQUEST', - KeySchema: [ - { AttributeName: 'id', KeyType: 'HASH' } // Partition key - ], - GlobalSecondaryIndexes: [ - { - IndexName: 'supplier-apim-index', - KeySchema: [ - { AttributeName: 'apimId', KeyType: 'HASH' } // Partition key for GSI - ], - Projection: { - ProjectionType: 'ALL' - } - } - ], - AttributeDefinitions: [ - { AttributeName: 'id', AttributeType: 'S' }, - { AttributeName: 'apimId', AttributeType: 'S' } - ] - }); - + TableName: "suppliers", + BillingMode: "PAY_PER_REQUEST", + KeySchema: [ + { AttributeName: "id", KeyType: "HASH" }, // Partition key + ], + GlobalSecondaryIndexes: [ + { + IndexName: "supplier-apim-index", + KeySchema: [ + { AttributeName: "apimId", KeyType: "HASH" }, // Partition key for GSI + ], + Projection: { + ProjectionType: "ALL", + }, + }, + ], + AttributeDefinitions: [ + { AttributeName: "id", AttributeType: "S" }, + { AttributeName: "apimId", AttributeType: "S" }, + ], +}); export async function createTables(context: DBContext) { const { ddbClient } = context; @@ -129,19 +128,24 @@ export async function createTables(context: DBContext) { await ddbClient.send(createSupplierTableCommand); } - export async function deleteTables(context: DBContext) { const { ddbClient } = context; - await ddbClient.send(new DeleteTableCommand({ - TableName: 'letters' - })); - - await ddbClient.send(new DeleteTableCommand({ - TableName: 'management-info' - })); - - await ddbClient.send(new DeleteTableCommand({ - TableName: 'suppliers' - })); + await ddbClient.send( + new DeleteTableCommand({ + TableName: "letters", + }), + ); + + await ddbClient.send( + new DeleteTableCommand({ + TableName: "management-info", + }), + ); + + await ddbClient.send( + new DeleteTableCommand({ + TableName: "suppliers", + }), + ); } diff --git a/internal/datastore/src/__test__/heathcheck.test.ts b/internal/datastore/src/__test__/heathcheck.test.ts index ade1b1b18..315144d78 100644 --- a/internal/datastore/src/__test__/heathcheck.test.ts +++ b/internal/datastore/src/__test__/heathcheck.test.ts @@ -1,12 +1,15 @@ -import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; -import { DBHealthcheck } from "../healthcheck"; -import { createTables, DBContext, deleteTables, setupDynamoDBContainer } from "./db"; +import DBHealthcheck from "../healthcheck"; +import { + DBContext, + createTables, + deleteTables, + setupDynamoDBContainer, +} from "./db"; // Database tests can take longer, especially with setup and teardown -jest.setTimeout(30000); - -describe('DBHealthcheck', () => { +jest.setTimeout(30_000); +describe("DBHealthcheck", () => { let db: DBContext; beforeAll(async () => { @@ -21,14 +24,16 @@ describe('DBHealthcheck', () => { await deleteTables(db); }); - it('passes when the database is available', async () => { + it("passes when the database is available", async () => { const dbHealthCheck = new DBHealthcheck(db.docClient, db.config); - await dbHealthCheck.check(); + await expect(dbHealthCheck.check()).resolves.not.toThrow(); }); - it('fails when the database is unavailable', async () => { + it("fails when the database is unavailable", async () => { const realFunction = db.docClient.send; - db.docClient.send = jest.fn().mockImplementation(() => { throw new Error('Failed to send')}); + db.docClient.send = jest.fn().mockImplementation(() => { + throw new Error("Failed to send"); + }); const dbHealthCheck = new DBHealthcheck(db.docClient, db.config); await expect(dbHealthCheck.check()).rejects.toThrow(); diff --git a/internal/datastore/src/__test__/letter-repository.test.ts b/internal/datastore/src/__test__/letter-repository.test.ts index d1714f903..6b0c52942 100644 --- a/internal/datastore/src/__test__/letter-repository.test.ts +++ b/internal/datastore/src/__test__/letter-repository.test.ts @@ -1,29 +1,37 @@ -import { createTables, DBContext, deleteTables, setupDynamoDBContainer } from './db'; -import { LetterRepository } from '../letter-repository'; -import { Letter } from '../types'; -import { Logger } from 'pino'; -import { createTestLogger, LogStream } from './logs'; -import { PutCommand } from '@aws-sdk/lib-dynamodb'; -import { LetterDto } from '../../../../lambdas/api-handler/src/contracts/letters'; - -function createLetter(supplierId: string, letterId: string, status: Letter['status'] = 'PENDING'): Omit { +import { Logger } from "pino"; +import { PutCommand } from "@aws-sdk/lib-dynamodb"; +import { + DBContext, + createTables, + deleteTables, + setupDynamoDBContainer, +} from "./db"; +import { LetterRepository } from "../letter-repository"; +import { Letter } from "../types"; +import { LogStream, createTestLogger } from "./logs"; +import { LetterDto } from "../../../../lambdas/api-handler/src/contracts/letters"; + +function createLetter( + supplierId: string, + letterId: string, + status: Letter["status"] = "PENDING", +): Omit { return { id: letterId, - supplierId: supplierId, - specificationId: 'specification1', - groupId: 'group1', + supplierId, + specificationId: "specification1", + groupId: "group1", url: `s3://bucket/${letterId}.pdf`, - status: status, + status, createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }; } // Database tests can take longer, especially with setup and teardown -jest.setTimeout(30000); - -describe('LetterRepository', () => { +jest.setTimeout(30_000); +describe("LetterRepository", () => { let db: DBContext; let letterRepository: LetterRepository; let logStream: LogStream; @@ -35,9 +43,7 @@ describe('LetterRepository', () => { beforeEach(async () => { await createTables(db); - ( - { logStream, logger } = createTestLogger() - ); + ({ logStream, logger } = createTestLogger()); letterRepository = new LetterRepository(db.docClient, logger, db.config); }); @@ -51,14 +57,18 @@ describe('LetterRepository', () => { await db.container.stop(); }); - async function checkLetterStatus(supplierId: string, letterId: string, status: Letter['status']) { + async function checkLetterStatus( + supplierId: string, + letterId: string, + status: Letter["status"], + ) { const letter = await letterRepository.getLetterById(supplierId, letterId); expect(letter.status).toBe(status); } - test('adds a letter to the database', async () => { - const supplierId = 'supplier1'; - const letterId = 'letter1'; + test("adds a letter to the database", async () => { + const supplierId = "supplier1"; + const letterId = "letter1"; await letterRepository.putLetter(createLetter(supplierId, letterId)); @@ -70,191 +80,264 @@ describe('LetterRepository', () => { expect(letter.reasonText).toBeUndefined(); }); - test('fetches a letter by id', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1')); - const letter = await letterRepository.getLetterById('supplier1', 'letter1'); - expect(letter).toEqual(expect.objectContaining({ - id: 'letter1', - supplierId: 'supplier1', - specificationId: 'specification1', - groupId: 'group1', - status: 'PENDING' - })); + test("fetches a letter by id", async () => { + await letterRepository.putLetter(createLetter("supplier1", "letter1")); + const letter = await letterRepository.getLetterById("supplier1", "letter1"); + expect(letter).toEqual( + expect.objectContaining({ + id: "letter1", + supplierId: "supplier1", + specificationId: "specification1", + groupId: "group1", + status: "PENDING", + }), + ); }); - test('throws an error when fetching a letter that does not exist', async () => { - await expect(letterRepository.getLetterById('supplier1', 'letter1')) - .rejects.toThrow('Letter with id letter1 not found for supplier supplier1'); + test("throws an error when fetching a letter that does not exist", async () => { + await expect( + letterRepository.getLetterById("supplier1", "letter1"), + ).rejects.toThrow( + "Letter with id letter1 not found for supplier supplier1", + ); }); - test('throws an error when creating a letter which already exists', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1')); - await expect(letterRepository.putLetter(createLetter('supplier1', 'letter1'))) - .rejects.toThrow('Letter with id letter1 already exists for supplier supplier1'); + test("throws an error when creating a letter which already exists", async () => { + await letterRepository.putLetter(createLetter("supplier1", "letter1")); + await expect( + letterRepository.putLetter(createLetter("supplier1", "letter1")), + ).rejects.toThrow( + "Letter with id letter1 already exists for supplier supplier1", + ); }); - test('rethrows errors from DynamoDB when creating a letter', async () => { + test("rethrows errors from DynamoDB when creating a letter", async () => { const misconfiguredRepository = new LetterRepository(db.docClient, logger, { ...db.config, - lettersTableName: 'nonexistent-table' + lettersTableName: "nonexistent-table", }); - await expect(misconfiguredRepository.putLetter(createLetter('supplier1', 'letter1'))) - .rejects.toThrow('Cannot do operations on a non-existent table'); + await expect( + misconfiguredRepository.putLetter(createLetter("supplier1", "letter1")), + ).rejects.toThrow("Cannot do operations on a non-existent table"); }); - test('updates a letter\'s status in the database', async () => { - const letter = createLetter('supplier1', 'letter1', 'PENDING'); + test("updates a letter's status in the database", async () => { + const letter = createLetter("supplier1", "letter1", "PENDING"); await letterRepository.putLetter(letter); - await checkLetterStatus('supplier1', 'letter1', 'PENDING'); + await checkLetterStatus("supplier1", "letter1", "PENDING"); const letterDto: LetterDto = { - id: 'letter1', - supplierId: 'supplier1', - status: 'REJECTED', - reasonCode: 'R01', - reasonText: 'Reason text' + id: "letter1", + supplierId: "supplier1", + status: "REJECTED", + reasonCode: "R01", + reasonText: "Reason text", }; await letterRepository.updateLetterStatus(letterDto); - const updatedLetter = await letterRepository.getLetterById('supplier1', 'letter1'); - expect(updatedLetter.status).toBe('REJECTED'); - expect(updatedLetter.reasonCode).toBe('R01'); - expect(updatedLetter.reasonText).toBe('Reason text'); + const updatedLetter = await letterRepository.getLetterById( + "supplier1", + "letter1", + ); + expect(updatedLetter.status).toBe("REJECTED"); + expect(updatedLetter.reasonCode).toBe("R01"); + expect(updatedLetter.reasonText).toBe("Reason text"); }); - test('updates a letter\'s updatedAt date', async () => { + test("updates a letter's updatedAt date", async () => { jest.useFakeTimers(); jest.setSystemTime(new Date(2020, 1, 1)); - await letterRepository.putLetter(createLetter('supplier1', 'letter1', 'PENDING')); - const originalLetter = await letterRepository.getLetterById('supplier1', 'letter1'); - expect(originalLetter.updatedAt).toBe('2020-02-01T00:00:00.000Z'); + await letterRepository.putLetter( + createLetter("supplier1", "letter1", "PENDING"), + ); + const originalLetter = await letterRepository.getLetterById( + "supplier1", + "letter1", + ); + expect(originalLetter.updatedAt).toBe("2020-02-01T00:00:00.000Z"); // Month is zero-indexed in JavaScript Date // Day is one-indexed jest.setSystemTime(new Date(2020, 1, 2)); const letterDto: LetterDto = { - id: 'letter1', - supplierId: 'supplier1', - status: 'DELIVERED' + id: "letter1", + supplierId: "supplier1", + status: "DELIVERED", }; await letterRepository.updateLetterStatus(letterDto); - const updatedLetter = await letterRepository.getLetterById('supplier1', 'letter1'); + const updatedLetter = await letterRepository.getLetterById( + "supplier1", + "letter1", + ); - expect(updatedLetter.updatedAt).toBe('2020-02-02T00:00:00.000Z'); + expect(updatedLetter.updatedAt).toBe("2020-02-02T00:00:00.000Z"); }); - test('can\'t update a letter that does not exist', async () => { + test("can't update a letter that does not exist", async () => { const letterDto: LetterDto = { - id: 'letter1', - supplierId: 'supplier1', - status: 'DELIVERED' + id: "letter1", + supplierId: "supplier1", + status: "DELIVERED", }; - await expect(letterRepository.updateLetterStatus(letterDto)) - .rejects.toThrow('Letter with id letter1 not found for supplier supplier1'); + await expect( + letterRepository.updateLetterStatus(letterDto), + ).rejects.toThrow( + "Letter with id letter1 not found for supplier supplier1", + ); }); - test('update letter status rethrows errors from DynamoDB', async () => { + test("update letter status rethrows errors from DynamoDB", async () => { const misconfiguredRepository = new LetterRepository(db.docClient, logger, { ...db.config, - lettersTableName: 'nonexistent-table' + lettersTableName: "nonexistent-table", }); const letterDto: LetterDto = { - id: 'letter1', - supplierId: 'supplier1', - status: 'DELIVERED' + id: "letter1", + supplierId: "supplier1", + status: "DELIVERED", }; - await expect(misconfiguredRepository.updateLetterStatus(letterDto)) - .rejects.toThrow('Cannot do operations on a non-existent table'); + await expect( + misconfiguredRepository.updateLetterStatus(letterDto), + ).rejects.toThrow("Cannot do operations on a non-existent table"); }); - test('should return a list of letters matching status', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1')); - await letterRepository.putLetter(createLetter('supplier1', 'letter2')); - await letterRepository.putLetter(createLetter('supplier1', 'letter3', 'DELIVERED')); + test("should return a list of letters matching status", async () => { + await letterRepository.putLetter(createLetter("supplier1", "letter1")); + await letterRepository.putLetter(createLetter("supplier1", "letter2")); + await letterRepository.putLetter( + createLetter("supplier1", "letter3", "DELIVERED"), + ); - const pendingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); + const pendingLetters = await letterRepository.getLettersByStatus( + "supplier1", + "PENDING", + ); expect(pendingLetters.letters).toHaveLength(2); - expect(pendingLetters.letters.map(l => l.id)).toEqual(['letter1', 'letter2']); + expect(pendingLetters.letters.map((l) => l.id)).toEqual([ + "letter1", + "letter2", + ]); - const deliveredLetters = await letterRepository.getLettersByStatus('supplier1', 'DELIVERED'); + const deliveredLetters = await letterRepository.getLettersByStatus( + "supplier1", + "DELIVERED", + ); expect(deliveredLetters.letters).toHaveLength(1); - expect(deliveredLetters.letters[0].id).toBe('letter3'); + expect(deliveredLetters.letters[0].id).toBe("letter3"); }); - test('letter list should change when letter status is updated', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1')); - await letterRepository.putLetter(createLetter('supplier1', 'letter2')); + test("letter list should change when letter status is updated", async () => { + await letterRepository.putLetter(createLetter("supplier1", "letter1")); + await letterRepository.putLetter(createLetter("supplier1", "letter2")); - const pendingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); + const pendingLetters = await letterRepository.getLettersByStatus( + "supplier1", + "PENDING", + ); expect(pendingLetters.letters).toHaveLength(2); const letterDto: LetterDto = { - id: 'letter1', - supplierId: 'supplier1', - status: 'DELIVERED' + id: "letter1", + supplierId: "supplier1", + status: "DELIVERED", }; await letterRepository.updateLetterStatus(letterDto); - const remainingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); + const remainingLetters = await letterRepository.getLettersByStatus( + "supplier1", + "PENDING", + ); expect(remainingLetters.letters).toHaveLength(1); - expect(remainingLetters.letters[0].id).toBe('letter2'); + expect(remainingLetters.letters[0].id).toBe("letter2"); - const updatedLetters = await letterRepository.getLettersByStatus('supplier1', 'DELIVERED'); + const updatedLetters = await letterRepository.getLettersByStatus( + "supplier1", + "DELIVERED", + ); expect(updatedLetters.letters).toHaveLength(1); - expect(updatedLetters.letters[0].id).toBe('letter1'); + expect(updatedLetters.letters[0].id).toBe("letter1"); }); - test('letter list should support pagination', async () => { + test("letter list should support pagination", async () => { for (let i = 1; i <= 99; i++) { - await letterRepository.putLetter(createLetter('supplier1', `letter${('000' + i).slice(-3)}`)); + const paddedId = i.toString().padStart(3, "0"); + await letterRepository.putLetter( + createLetter("supplier1", `letter${paddedId}`), + ); } - const firstPage = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); + const firstPage = await letterRepository.getLettersByStatus( + "supplier1", + "PENDING", + ); expect(firstPage.letters).toHaveLength(50); // Default page size is 50 expect(firstPage.lastEvaluatedKey).toBeDefined(); - expect(firstPage.letters[0].id).toBe('letter001'); - expect(firstPage.letters[49].id).toBe('letter050'); + expect(firstPage.letters[0].id).toBe("letter001"); + expect(firstPage.letters[49].id).toBe("letter050"); - const secondPage = await letterRepository.getLettersByStatus('supplier1', 'PENDING', { - exclusiveStartKey: firstPage.lastEvaluatedKey - }); + const secondPage = await letterRepository.getLettersByStatus( + "supplier1", + "PENDING", + { + exclusiveStartKey: firstPage.lastEvaluatedKey, + }, + ); expect(secondPage.letters).toHaveLength(49); expect(secondPage.lastEvaluatedKey).toBeUndefined(); // No more pages - expect(secondPage.letters[0].id).toBe('letter051'); - expect(secondPage.letters[48].id).toBe('letter099'); + expect(secondPage.letters[0].id).toBe("letter051"); + expect(secondPage.letters[48].id).toBe("letter099"); }); - test('letter list should return empty when no letters match status', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1', 'ACCEPTED')); - const page = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); + test("letter list should return empty when no letters match status", async () => { + await letterRepository.putLetter( + createLetter("supplier1", "letter1", "ACCEPTED"), + ); + const page = await letterRepository.getLettersByStatus( + "supplier1", + "PENDING", + ); expect(page.letters).toHaveLength(0); expect(page.lastEvaluatedKey).toBeUndefined(); }); - test('letter list should warn about invalid data', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1')); - await db.docClient.send(new PutCommand({ - TableName: db.config.lettersTableName, - Item: { - supplierId: 'supplier1', - id: 'invalid-letter', - // specificationId: 'specification1', // Missing required field - groupId: 'group1', - url: 's3://bucket/invalid-letter.pdf', - status: 'PENDING', - supplierStatus: 'supplier1#PENDING', - supplierStatusSk: Date.now().toString(), - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString() - } - })); - - const pendingLetters = await letterRepository.getLettersByStatus('supplier1', 'PENDING'); - expect(pendingLetters.letters).toHaveLength(1); - expect(pendingLetters.letters[0].id).toBe('letter1'); + test("letter list should warn about invalid data", async () => { + await letterRepository.putLetter(createLetter("supplier1", "letter1")); + await db.docClient.send( + new PutCommand({ + TableName: db.config.lettersTableName, + Item: { + supplierId: "supplier1", + id: "invalid-letter", + // specificationId: 'specification1', // Missing required field + groupId: "group1", + url: "s3://bucket/invalid-letter.pdf", + status: "PENDING", + supplierStatus: "supplier1#PENDING", + supplierStatusSk: Date.now().toString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }), + ); - expect(logStream.logs).toContainEqual(expect.stringMatching(/.*Invalid letter data:.*/)); - expect(logStream.logs).toContainEqual(expect.stringMatching(/.*specificationId.*Invalid input: expected string.*/)); + const pendingLetters = await letterRepository.getLettersByStatus( + "supplier1", + "PENDING", + ); + expect(pendingLetters.letters).toHaveLength(1); + expect(pendingLetters.letters[0].id).toBe("letter1"); + + expect( + logStream.logs.some((log) => log.includes("Invalid letter data:")), + ).toBe(true); + + expect( + logStream.logs.some( + (log) => + log.includes("specificationId") && + log.includes("Invalid input: expected string"), + ), + ).toBe(true); }); test("should return all letters for a supplier status", async () => { @@ -276,95 +359,102 @@ describe('LetterRepository', () => { { id: "letter1", specificationId: "specification1", - groupId: 'group1', + groupId: "group1", status: "PENDING", }, { id: "letter2", specificationId: "specification1", - groupId: 'group1', + groupId: "group1", status: "PENDING", }, { id: "letter3", specificationId: "specification1", - groupId: 'group1', + groupId: "group1", status: "PENDING", }, ]); }); - test('should return empty if no letters exist for a supplier', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1')); - await letterRepository.putLetter(createLetter('supplier1', 'letter2')); + test("should return empty if no letters exist for a supplier", async () => { + await letterRepository.putLetter(createLetter("supplier1", "letter1")); + await letterRepository.putLetter(createLetter("supplier1", "letter2")); - const letters = await letterRepository.getLettersBySupplier('supplier2', 'PENDING', 10); + const letters = await letterRepository.getLettersBySupplier( + "supplier2", + "PENDING", + 10, + ); expect(letters).toEqual([]); }); - test('should return empty if query result Items is null', async () => { - await letterRepository.putLetter(createLetter('supplier1', 'letter1')); + test("should return empty if query result Items is null", async () => { + await letterRepository.putLetter(createLetter("supplier1", "letter1")); const mockSend = jest.fn().mockResolvedValue({ Items: null }); const mockDdbClient = { send: mockSend } as any; - const repo = new LetterRepository(mockDdbClient, { debug: jest.fn() } as any, { lettersTableName: 'letters', lettersTtlHours: 1 }); + const repo = new LetterRepository( + mockDdbClient, + { debug: jest.fn() } as any, + { lettersTableName: "letters", lettersTtlHours: 1 }, + ); - const letters = await repo.getLettersBySupplier('supplier1', 'PENDING', 10); + const letters = await repo.getLettersBySupplier("supplier1", "PENDING", 10); expect(letters).toEqual([]); }); - test('should batch write letters to the database', async () => { + test("should batch write letters to the database", async () => { const letters = [ - createLetter('supplier1', 'letter1'), - createLetter('supplier1', 'letter2'), - createLetter('supplier1', 'letter3') + createLetter("supplier1", "letter1"), + createLetter("supplier1", "letter2"), + createLetter("supplier1", "letter3"), ]; await letterRepository.putLetterBatch(letters); - await checkLetterStatus('supplier1', 'letter1', 'PENDING'); - await checkLetterStatus('supplier1', 'letter2', 'PENDING'); - await checkLetterStatus('supplier1', 'letter3', 'PENDING'); + await checkLetterStatus("supplier1", "letter1", "PENDING"); + await checkLetterStatus("supplier1", "letter2", "PENDING"); + await checkLetterStatus("supplier1", "letter3", "PENDING"); }); - test('should batch in calls upto 25', async () => { - const letters = [] - for(let i=0; i<60; i++) { - letters.push(createLetter('supplier1', `letter${i}`)); + test("should batch in calls upto 25", async () => { + const letters = []; + for (let i = 0; i < 60; i++) { + letters.push(createLetter("supplier1", `letter${i}`)); } - const sendSpy = jest.spyOn(db.docClient, 'send'); + const sendSpy = jest.spyOn(db.docClient, "send"); await letterRepository.putLetterBatch(letters); expect(sendSpy).toHaveBeenCalledTimes(3); - await checkLetterStatus('supplier1', 'letter1', 'PENDING'); - await checkLetterStatus('supplier1', 'letter6', 'PENDING'); - await checkLetterStatus('supplier1', 'letter59', 'PENDING'); + await checkLetterStatus("supplier1", "letter1", "PENDING"); + await checkLetterStatus("supplier1", "letter6", "PENDING"); + await checkLetterStatus("supplier1", "letter59", "PENDING"); }); - test('should skip array gaps', async () => { - const letters = [ - createLetter('supplier1', 'letter1'), - createLetter('supplier1', 'letter2'), - createLetter('supplier1', 'letter3') - ]; - - delete letters[1]; + test("should skip array gaps", async () => { + const letters = []; + letters[0] = createLetter("supplier1", "letter1"); + letters[2] = createLetter("supplier1", "letter3"); await letterRepository.putLetterBatch(letters); - await checkLetterStatus('supplier1', 'letter1', 'PENDING'); - await checkLetterStatus('supplier1', 'letter3', 'PENDING'); + await checkLetterStatus("supplier1", "letter1", "PENDING"); + await checkLetterStatus("supplier1", "letter3", "PENDING"); }); - test('rethrows errors from DynamoDB when batch creating letter', async () => { + test("rethrows errors from DynamoDB when batch creating letter", async () => { const misconfiguredRepository = new LetterRepository(db.docClient, logger, { ...db.config, - lettersTableName: 'nonexistent-table' + lettersTableName: "nonexistent-table", }); - await expect(misconfiguredRepository.putLetterBatch([createLetter('supplier1', 'letter1')])) - .rejects.toThrow('Cannot do operations on a non-existent table'); + await expect( + misconfiguredRepository.putLetterBatch([ + createLetter("supplier1", "letter1"), + ]), + ).rejects.toThrow("Cannot do operations on a non-existent table"); }); }); diff --git a/internal/datastore/src/__test__/logs.ts b/internal/datastore/src/__test__/logs.ts index e9411de9d..e96b639be 100644 --- a/internal/datastore/src/__test__/logs.ts +++ b/internal/datastore/src/__test__/logs.ts @@ -1,5 +1,5 @@ -import pino from 'pino'; -import { Writable } from 'stream'; +import pino from "pino"; +import { Writable } from "node:stream"; export class LogStream extends Writable { logs: string[] = []; @@ -11,9 +11,9 @@ export class LogStream extends Writable { } export function createTestLogger() { - let logStream = new LogStream(); + const logStream = new LogStream(); return { - logStream: logStream, - logger: pino(logStream) + logStream, + logger: pino(logStream), }; } diff --git a/internal/datastore/src/__test__/mi-repository.test.ts b/internal/datastore/src/__test__/mi-repository.test.ts index b0fa2e392..dd34e5d15 100644 --- a/internal/datastore/src/__test__/mi-repository.test.ts +++ b/internal/datastore/src/__test__/mi-repository.test.ts @@ -1,28 +1,28 @@ import { Logger } from "pino"; -import { setupDynamoDBContainer, createTables, DBContext, deleteTables } from "./db"; -import { createTestLogger, LogStream } from "./logs"; +import { + DBContext, + createTables, + deleteTables, + setupDynamoDBContainer, +} from "./db"; +import { createTestLogger } from "./logs"; import { MIRepository } from "../mi-repository"; // Database tests can take longer, especially with setup and teardown -jest.setTimeout(30000); +jest.setTimeout(30_000); - -describe('MiRepository', () => { +describe("MiRepository", () => { let db: DBContext; let miRepository: MIRepository; - let logStream: LogStream; let logger: Logger; - beforeAll(async () => { db = await setupDynamoDBContainer(); }); beforeEach(async () => { await createTables(db); - ( - { logStream, logger } = createTestLogger() - ); + ({ logger } = createTestLogger()); miRepository = new MIRepository(db.docClient, logger, db.config); }); @@ -36,32 +36,32 @@ describe('MiRepository', () => { await db.container.stop(); }); - describe('putMi', () => { - - it('creates a letter with id and timestamps', async () => { - + describe("putMi", () => { + it("creates a letter with id and timestamps", async () => { jest.useFakeTimers(); // Month is zero-indexed in JS Date jest.setSystemTime(new Date(2020, 1, 1)); const mi = { - specificationId: 'spec1', - supplierId: 'supplier1', - groupId:'group1', - lineItem: 'item1', + specificationId: "spec1", + supplierId: "supplier1", + groupId: "group1", + lineItem: "item1", quantity: 12, timestamp: new Date().toISOString(), - stockRemaining: 0 + stockRemaining: 0, }; - const persistedMi = await(miRepository.putMI(mi)); + const persistedMi = await miRepository.putMI(mi); - expect(persistedMi).toEqual(expect.objectContaining({ - id: expect.any(String), - createdAt: '2020-02-01T00:00:00.000Z', - updatedAt: '2020-02-01T00:00:00.000Z', - ttl: 1580518800, // 2020-02-01T00:01:00.000Z, seconds since epoch - ...mi - })); + expect(persistedMi).toEqual( + expect.objectContaining({ + id: expect.any(String), + createdAt: "2020-02-01T00:00:00.000Z", + updatedAt: "2020-02-01T00:00:00.000Z", + ttl: 1_580_518_800, // 2020-02-01T00:01:00.000Z, seconds since epoch + ...mi, + }), + ); }); }); }); diff --git a/internal/datastore/src/__test__/supplier-repository.test.ts b/internal/datastore/src/__test__/supplier-repository.test.ts index c74565745..23f98cdd2 100644 --- a/internal/datastore/src/__test__/supplier-repository.test.ts +++ b/internal/datastore/src/__test__/supplier-repository.test.ts @@ -1,127 +1,151 @@ import { Logger } from "pino"; -import { createTables, DBContext, deleteTables, setupDynamoDBContainer } from "./db"; -import { createTestLogger, LogStream } from "./logs"; +import { randomUUID } from "node:crypto"; +import { + DBContext, + createTables, + deleteTables, + setupDynamoDBContainer, +} from "./db"; +import { createTestLogger } from "./logs"; import { SupplierRepository } from "../supplier-repository"; import { Supplier } from "../types"; -import { v4 as uuidv4 } from 'uuid'; - -function createSupplier(status: 'ENABLED' | 'DISABLED', apimId = uuidv4()): Omit { - return { - id: uuidv4(), - name: 'Supplier One', - apimId, - status - } + +function createSupplier( + status: "ENABLED" | "DISABLED", + apimId = randomUUID(), +): Omit { + return { + id: randomUUID(), + name: "Supplier One", + apimId, + status, + }; } // Database tests can take longer, especially with setup and teardown -jest.setTimeout(30000); - -describe('SupplierRepository', () => { - let db: DBContext; - let supplierRepository: SupplierRepository; - let logStream: LogStream; - let logger: Logger; - - beforeAll(async () => { - db = await setupDynamoDBContainer(); - }); - - beforeEach(async () => { - await createTables(db); - ( - { logStream, logger } = createTestLogger() - ); - - supplierRepository = new SupplierRepository(db.docClient, logger, db.config); - }); - - afterEach(async () => { - await deleteTables(db); - jest.useRealTimers(); - }); - - afterAll(async () => { - await db.container.stop(); - }); - - test('creates an enabled supplier with provided values and timestamps', async () => { - jest.useFakeTimers(); - // Month is zero-indexed in JS Date - jest.setSystemTime(new Date(2020, 1, 1)); - - const supplier = createSupplier('ENABLED'); - - const persistedSupplier = await supplierRepository.putSupplier(supplier); - - expect(persistedSupplier).toEqual(expect.objectContaining({ - ...supplier, - updatedAt: '2020-02-01T00:00:00.000Z', - })); - }); - - test('fetches a supplier by its ID', async () => { - const supplier = createSupplier('DISABLED') - await supplierRepository.putSupplier(supplier); - - const fetched = await supplierRepository.getSupplierById(supplier.id); - - expect(fetched).toEqual(expect.objectContaining({ - ...supplier - })); - }); - - test('throws an error fetching a supplier that does not exist', async () => { - await expect(supplierRepository.getSupplierById('non-existent-id')) - .rejects.toThrow('Supplier with id non-existent-id not found'); - }); - - test('overwrites an existing supplier entry', async () => { - const supplier = createSupplier('DISABLED'); - - const original = await supplierRepository.putSupplier(supplier); - expect(original.status).toBe('DISABLED'); - - supplier.status = 'ENABLED'; - const updated = await supplierRepository.putSupplier(supplier); - expect(updated.status).toBe('ENABLED'); - }); - - test('rethrows errors from DynamoDB when creating a letter', async () => { - const misconfiguredRepository = new SupplierRepository(db.docClient, logger, { - ...db.config, - suppliersTableName: 'nonexistent-table' - }); - await expect(misconfiguredRepository.putSupplier(createSupplier('ENABLED'))) - .rejects.toThrow('Cannot do operations on a non-existent table'); - }); - - test('fetches a supplier by apimId', async () => { - const supplier = createSupplier('ENABLED'); - - await supplierRepository.putSupplier(supplier); - - const fetched = await supplierRepository.getSupplierByApimId(supplier.apimId); - expect(fetched).toEqual(expect.objectContaining({ - ...supplier - })); - }); - - test('throws an error fetching a supplier by apimId that does not exist', async () => { - await expect(supplierRepository.getSupplierByApimId('non-existent-apim-id')) - .rejects.toThrow('Supplier with apimId non-existent-apim-id not found'); - }); - - test('throws an error fetching a supplier by apimId when multiple exist', async () => { - const apimId = 'duplicate-apim-id'; - const supplier1 = createSupplier('ENABLED', apimId); - const supplier2 = createSupplier('DISABLED', apimId); - - await supplierRepository.putSupplier(supplier1); - await supplierRepository.putSupplier(supplier2); - - await expect(supplierRepository.getSupplierByApimId(apimId)) - .rejects.toThrow(`Multiple suppliers found with apimId ${apimId}`); - }); - +jest.setTimeout(30_000); + +describe("SupplierRepository", () => { + let db: DBContext; + let supplierRepository: SupplierRepository; + let logger: Logger; + + beforeAll(async () => { + db = await setupDynamoDBContainer(); + }); + + beforeEach(async () => { + await createTables(db); + ({ logger } = createTestLogger()); + + supplierRepository = new SupplierRepository( + db.docClient, + logger, + db.config, + ); + }); + + afterEach(async () => { + await deleteTables(db); + jest.useRealTimers(); + }); + + afterAll(async () => { + await db.container.stop(); + }); + + test("creates an enabled supplier with provided values and timestamps", async () => { + jest.useFakeTimers(); + // Month is zero-indexed in JS Date + jest.setSystemTime(new Date(2020, 1, 1)); + + const supplier = createSupplier("ENABLED"); + + const persistedSupplier = await supplierRepository.putSupplier(supplier); + + expect(persistedSupplier).toEqual( + expect.objectContaining({ + ...supplier, + updatedAt: "2020-02-01T00:00:00.000Z", + }), + ); + }); + + test("fetches a supplier by its ID", async () => { + const supplier = createSupplier("DISABLED"); + await supplierRepository.putSupplier(supplier); + + const fetched = await supplierRepository.getSupplierById(supplier.id); + + expect(fetched).toEqual( + expect.objectContaining({ + ...supplier, + }), + ); + }); + + test("throws an error fetching a supplier that does not exist", async () => { + await expect( + supplierRepository.getSupplierById("non-existent-id"), + ).rejects.toThrow("Supplier with id non-existent-id not found"); + }); + + test("overwrites an existing supplier entry", async () => { + const supplier = createSupplier("DISABLED"); + + const original = await supplierRepository.putSupplier(supplier); + expect(original.status).toBe("DISABLED"); + + supplier.status = "ENABLED"; + const updated = await supplierRepository.putSupplier(supplier); + expect(updated.status).toBe("ENABLED"); + }); + + test("rethrows errors from DynamoDB when creating a letter", async () => { + const misconfiguredRepository = new SupplierRepository( + db.docClient, + logger, + { + ...db.config, + suppliersTableName: "nonexistent-table", + }, + ); + await expect( + misconfiguredRepository.putSupplier(createSupplier("ENABLED")), + ).rejects.toThrow("Cannot do operations on a non-existent table"); + }); + + test("fetches a supplier by apimId", async () => { + const supplier = createSupplier("ENABLED"); + + await supplierRepository.putSupplier(supplier); + + const fetched = await supplierRepository.getSupplierByApimId( + supplier.apimId, + ); + expect(fetched).toEqual( + expect.objectContaining({ + ...supplier, + }), + ); + }); + + test("throws an error fetching a supplier by apimId that does not exist", async () => { + await expect( + supplierRepository.getSupplierByApimId("non-existent-apim-id"), + ).rejects.toThrow("Supplier with apimId non-existent-apim-id not found"); + }); + + test("throws an error fetching a supplier by apimId when multiple exist", async () => { + const apimId = randomUUID(); + const supplier1 = createSupplier("ENABLED", apimId); + const supplier2 = createSupplier("DISABLED", apimId); + + await supplierRepository.putSupplier(supplier1); + await supplierRepository.putSupplier(supplier2); + + await expect( + supplierRepository.getSupplierByApimId(apimId), + ).rejects.toThrow(`Multiple suppliers found with apimId ${apimId}`); + }); }); diff --git a/internal/datastore/src/config.ts b/internal/datastore/src/config.ts index b9a4f6ec2..6440942e8 100644 --- a/internal/datastore/src/config.ts +++ b/internal/datastore/src/config.ts @@ -1,9 +1,9 @@ export type DatastoreConfig = { - region: string, - endpoint?: string, - lettersTableName: string, - miTableName: string, - suppliersTableName: string, - lettersTtlHours: number, - miTtlHours: number -} + region: string; + endpoint?: string; + lettersTableName: string; + miTableName: string; + suppliersTableName: string; + lettersTtlHours: number; + miTtlHours: number; +}; diff --git a/internal/datastore/src/healthcheck.ts b/internal/datastore/src/healthcheck.ts index 39c91c619..62e1aa1b8 100644 --- a/internal/datastore/src/healthcheck.ts +++ b/internal/datastore/src/healthcheck.ts @@ -2,12 +2,17 @@ import { DescribeTableCommand } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb"; import { LetterRepositoryConfig } from "./letter-repository"; -export class DBHealthcheck { - constructor(readonly ddbClient: DynamoDBDocumentClient, - readonly config: LetterRepositoryConfig) {} +export default class DBHealthcheck { + constructor( + readonly ddbClient: DynamoDBDocumentClient, + readonly config: LetterRepositoryConfig, + ) {} async check(): Promise { - await this.ddbClient.send(new DescribeTableCommand({ - TableName: this.config.lettersTableName})); + await this.ddbClient.send( + new DescribeTableCommand({ + TableName: this.config.lettersTableName, + }), + ); } } diff --git a/internal/datastore/src/index.ts b/internal/datastore/src/index.ts index 5073399b2..53d52cd6b 100644 --- a/internal/datastore/src/index.ts +++ b/internal/datastore/src/index.ts @@ -1,6 +1,5 @@ -export * from './types'; -export * from './mi-repository'; -export * from './letter-repository'; -export * from './supplier-repository'; -export * from './healthcheck'; -export * from './types'; +export * from "./types"; +export * from "./mi-repository"; +export * from "./letter-repository"; +export * from "./supplier-repository"; +export { default as DBHealthcheck } from "./healthcheck"; diff --git a/internal/datastore/src/letter-repository.ts b/internal/datastore/src/letter-repository.ts index 05bc0e3d2..e9fd85d0c 100644 --- a/internal/datastore/src/letter-repository.ts +++ b/internal/datastore/src/letter-repository.ts @@ -1,85 +1,98 @@ import { + BatchWriteCommand, DynamoDBDocumentClient, GetCommand, PutCommand, - BatchWriteCommand, QueryCommand, UpdateCommand, - UpdateCommandOutput -} from '@aws-sdk/lib-dynamodb'; -import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from './types'; -import { Logger } from 'pino'; -import { z } from 'zod'; -import { LetterDto } from '../../../lambdas/api-handler/src/contracts/letters'; + UpdateCommandOutput, +} from "@aws-sdk/lib-dynamodb"; +import { Logger } from "pino"; +import { z } from "zod"; +import { Letter, LetterBase, LetterSchema, LetterSchemaBase } from "./types"; +import { LetterDto } from "../../../lambdas/api-handler/src/contracts/letters"; export type PagingOptions = Partial<{ - exclusiveStartKey: Record, - pageSize: number -}> + exclusiveStartKey: Record; + pageSize: number; +}>; const defaultPagingOptions = { - pageSize: 50 + pageSize: 50, }; export type LetterRepositoryConfig = { - lettersTableName: string, - lettersTtlHours: number -} + lettersTableName: string; + lettersTtlHours: number; +}; export class LetterRepository { - constructor(readonly ddbClient: DynamoDBDocumentClient, + constructor( + readonly ddbClient: DynamoDBDocumentClient, readonly log: Logger, - readonly config: LetterRepositoryConfig) { - } + readonly config: LetterRepositoryConfig, + ) {} - async putLetter(letter: Omit): Promise { + async putLetter( + letter: Omit, + ): Promise { const letterDb: Letter = { ...letter, supplierStatus: `${letter.supplierId}#${letter.status}`, supplierStatusSk: new Date().toISOString(), - ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours) + ttl: Math.floor( + Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, + ), }; try { - await this.ddbClient.send(new PutCommand({ - TableName: this.config.lettersTableName, - Item: letterDb, - ConditionExpression: 'attribute_not_exists(id)', // Ensure id is unique - })); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.lettersTableName, + Item: letterDb, + ConditionExpression: "attribute_not_exists(id)", // Ensure id is unique + }), + ); } catch (error) { - if (error instanceof Error && error.name === 'ConditionalCheckFailedException') { - throw new Error(`Letter with id ${letter.id} already exists for supplier ${letter.supplierId}`); + if ( + error instanceof Error && + error.name === "ConditionalCheckFailedException" + ) { + throw new Error( + `Letter with id ${letter.id} already exists for supplier ${letter.supplierId}`, + ); } throw error; } return LetterSchema.parse(letterDb); } - async putLetterBatch(letters: Omit[]): Promise { + async putLetterBatch( + letters: Omit[], + ): Promise { let lettersDb: Letter[] = []; for (let i = 0; i < letters.length; i++) { - const letter = letters[i]; - if(!letter){ - continue; + if (letter) { + lettersDb.push({ + ...letter, + supplierStatus: `${letter.supplierId}#${letter.status}`, + supplierStatusSk: Date.now().toString(), + ttl: Math.floor( + Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, + ), + }); } - lettersDb.push({ - ...letter, - supplierStatus: `${letter.supplierId}#${letter.status}`, - supplierStatusSk: Date.now().toString(), - ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours) - }); - if (lettersDb.length === 25 || i === letters.length - 1) { const input = { RequestItems: { [this.config.lettersTableName]: lettersDb.map((item: any) => ({ PutRequest: { - Item: item - } - })) - } + Item: item, + }, + })), + }, }; await this.ddbClient.send(new BatchWriteCommand(input)); @@ -90,115 +103,148 @@ export class LetterRepository { } async getLetterById(supplierId: string, letterId: string): Promise { - const result = await this.ddbClient.send(new GetCommand({ - TableName: this.config.lettersTableName, - Key: { - supplierId: supplierId, - id: letterId - } - })); + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.lettersTableName, + Key: { + supplierId, + id: letterId, + }, + }), + ); if (!result.Item) { - throw new Error(`Letter with id ${letterId} not found for supplier ${supplierId}`); + throw new Error( + `Letter with id ${letterId} not found for supplier ${supplierId}`, + ); } return LetterSchema.parse(result.Item); } - async getLettersByStatus(supplierId: string, status: Letter['status'], options?: PagingOptions): Promise<{ - letters: Letter[], - lastEvaluatedKey?: Record + async getLettersByStatus( + supplierId: string, + status: Letter["status"], + options?: PagingOptions, + ): Promise<{ + letters: Letter[]; + lastEvaluatedKey?: Record; }> { - const extendedOptions = { ...defaultPagingOptions, ...options }; - const result = await this.ddbClient.send(new QueryCommand({ - TableName: this.config.lettersTableName, - IndexName: 'supplierStatus-index', - KeyConditionExpression: 'supplierStatus = :supplierStatus', - ExpressionAttributeValues: { ':supplierStatus': `${supplierId}#${status}` }, - Limit: extendedOptions.pageSize, - ExclusiveStartKey: extendedOptions.exclusiveStartKey - })); + const result = await this.ddbClient.send( + new QueryCommand({ + TableName: this.config.lettersTableName, + IndexName: "supplierStatus-index", + KeyConditionExpression: "supplierStatus = :supplierStatus", + ExpressionAttributeValues: { + ":supplierStatus": `${supplierId}#${status}`, + }, + Limit: extendedOptions.pageSize, + ExclusiveStartKey: extendedOptions.exclusiveStartKey, + }), + ); + + // Items is an empty array if no items match the query + const letters = result + .Items!.map((item) => LetterSchema.safeParse(item)) + .filter((letterItem) => { + if (!letterItem.success) { + this.log.warn(`Invalid letter data: ${letterItem.error}`); + } + return letterItem.success; + }) + .map((successLetterItem) => successLetterItem.data); return { - // Items is an empty array if no items match the query - letters: result.Items!.map(item => LetterSchema.safeParse(item)) - .filter((result) => { - if (!result.success) { - this.log.warn(`Invalid letter data: ${result.error}`); - } - return result.success; - }) - .map(result => result.data), - lastEvaluatedKey: result.LastEvaluatedKey - } + letters, + lastEvaluatedKey: result.LastEvaluatedKey, + }; } async updateLetterStatus(letterToUpdate: LetterDto): Promise { - this.log.debug(`Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`); + this.log.debug( + `Updating letter ${letterToUpdate.id} to status ${letterToUpdate.status}`, + ); let result: UpdateCommandOutput; try { - let updateExpression = 'set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl'; - let expressionAttributeValues : Record = { - ':status': letterToUpdate.status, - ':updatedAt': new Date().toISOString(), - ':supplierStatus': `${letterToUpdate.supplierId}#${letterToUpdate.status}`, - ':ttl': Math.floor(Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours) + let updateExpression = + "set #status = :status, updatedAt = :updatedAt, supplierStatus = :supplierStatus, #ttl = :ttl"; + const expressionAttributeValues: Record = { + ":status": letterToUpdate.status, + ":updatedAt": new Date().toISOString(), + ":supplierStatus": `${letterToUpdate.supplierId}#${letterToUpdate.status}`, + ":ttl": Math.floor( + Date.now() / 1000 + 60 * 60 * this.config.lettersTtlHours, + ), }; - if (letterToUpdate.reasonCode) - { - updateExpression += ', reasonCode = :reasonCode'; - expressionAttributeValues[':reasonCode'] = letterToUpdate.reasonCode; + if (letterToUpdate.reasonCode) { + updateExpression += ", reasonCode = :reasonCode"; + expressionAttributeValues[":reasonCode"] = letterToUpdate.reasonCode; } - if (letterToUpdate.reasonText) - { - updateExpression += ', reasonText = :reasonText'; - expressionAttributeValues[':reasonText'] = letterToUpdate.reasonText; + if (letterToUpdate.reasonText) { + updateExpression += ", reasonText = :reasonText"; + expressionAttributeValues[":reasonText"] = letterToUpdate.reasonText; } - result = await this.ddbClient.send(new UpdateCommand({ - TableName: this.config.lettersTableName, - Key: { - supplierId: letterToUpdate.supplierId, - id: letterToUpdate.id - }, - UpdateExpression: updateExpression, - ConditionExpression: 'attribute_exists(id)', // Ensure letter exists - ExpressionAttributeNames: { - '#status': 'status', - '#ttl': 'ttl' - }, - ExpressionAttributeValues: expressionAttributeValues, - ReturnValues: 'ALL_NEW' - })); + result = await this.ddbClient.send( + new UpdateCommand({ + TableName: this.config.lettersTableName, + Key: { + supplierId: letterToUpdate.supplierId, + id: letterToUpdate.id, + }, + UpdateExpression: updateExpression, + ConditionExpression: "attribute_exists(id)", // Ensure letter exists + ExpressionAttributeNames: { + "#status": "status", + "#ttl": "ttl", + }, + ExpressionAttributeValues: expressionAttributeValues, + ReturnValues: "ALL_NEW", + }), + ); } catch (error) { - if (error instanceof Error && error.name === 'ConditionalCheckFailedException') { - throw new Error(`Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`); + if ( + error instanceof Error && + error.name === "ConditionalCheckFailedException" + ) { + throw new Error( + `Letter with id ${letterToUpdate.id} not found for supplier ${letterToUpdate.supplierId}`, + ); } throw error; } - this.log.debug(`Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`); + this.log.debug( + `Updated letter ${letterToUpdate.id} to status ${letterToUpdate.status}`, + ); return LetterSchema.parse(result.Attributes); } - async getLettersBySupplier(supplierId: string, status: string, limit: number): Promise { + async getLettersBySupplier( + supplierId: string, + status: string, + limit: number, + ): Promise { 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' - })); + 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 ?? []); } } diff --git a/internal/datastore/src/mi-repository.ts b/internal/datastore/src/mi-repository.ts index c3d1fd353..1a92fe715 100644 --- a/internal/datastore/src/mi-repository.ts +++ b/internal/datastore/src/mi-repository.ts @@ -1,38 +1,39 @@ -import { - DynamoDBDocumentClient, - PutCommand -} from '@aws-sdk/lib-dynamodb'; -import { MI, MISchema } from './types'; -import { Logger } from 'pino'; -import { v4 as uuidv4 } from 'uuid'; +import { DynamoDBDocumentClient, PutCommand } from "@aws-sdk/lib-dynamodb"; +import { Logger } from "pino"; +import { randomUUID } from "node:crypto"; +import { MI, MISchema } from "./types"; export type MIRepositoryConfig = { - miTableName: string, - miTtlHours: number + miTableName: string; + miTtlHours: number; }; export class MIRepository { - constructor(readonly ddbClient: DynamoDBDocumentClient, + constructor( + readonly ddbClient: DynamoDBDocumentClient, readonly log: Logger, - readonly config: MIRepositoryConfig) { - } - - async putMI(mi: Omit): Promise { + readonly config: MIRepositoryConfig, + ) {} + async putMI( + mi: Omit, + ): Promise { const now = new Date().toISOString(); const miDb = { ...mi, - id: uuidv4(), + id: randomUUID(), createdAt: now, updatedAt: now, - ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.miTtlHours) + ttl: Math.floor(Date.now() / 1000 + 60 * 60 * this.config.miTtlHours), }; - await this.ddbClient.send(new PutCommand({ - TableName: this.config.miTableName, - Item: miDb - })); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.miTableName, + Item: miDb, + }), + ); return MISchema.parse(miDb); } -}; +} diff --git a/internal/datastore/src/supplier-repository.ts b/internal/datastore/src/supplier-repository.ts index 14a827659..8846a28b5 100644 --- a/internal/datastore/src/supplier-repository.ts +++ b/internal/datastore/src/supplier-repository.ts @@ -2,70 +2,76 @@ import { DynamoDBDocumentClient, GetCommand, PutCommand, - QueryCommand -} from '@aws-sdk/lib-dynamodb'; -import { Supplier, SupplierSchema } from './types'; -import { Logger } from 'pino'; + QueryCommand, +} from "@aws-sdk/lib-dynamodb"; +import { Logger } from "pino"; +import { Supplier, SupplierSchema } from "./types"; export type SupplierRepositoryConfig = { - suppliersTableName: string + suppliersTableName: string; }; export class SupplierRepository { - constructor(readonly ddbClient: DynamoDBDocumentClient, + constructor( + readonly ddbClient: DynamoDBDocumentClient, readonly log: Logger, - readonly config: SupplierRepositoryConfig) { - } - - async putSupplier(supplier: Omit): Promise { + readonly config: SupplierRepositoryConfig, + ) {} + async putSupplier(supplier: Omit): Promise { const now = new Date().toISOString(); const supplierDb = { ...supplier, - updatedAt: now + updatedAt: now, }; - await this.ddbClient.send(new PutCommand({ - TableName: this.config.suppliersTableName, - Item: supplierDb, - })); + await this.ddbClient.send( + new PutCommand({ + TableName: this.config.suppliersTableName, + Item: supplierDb, + }), + ); return SupplierSchema.parse(supplierDb); } async getSupplierById(supplierId: string): Promise { - const result = await this.ddbClient.send(new GetCommand({ - TableName: this.config.suppliersTableName, - Key: { - id: supplierId - } - })); + const result = await this.ddbClient.send( + new GetCommand({ + TableName: this.config.suppliersTableName, + Key: { + id: supplierId, + }, + }), + ); if (!result.Item) { - throw new Error(`Supplier with id ${supplierId} not found`); + throw new Error(`Supplier with id ${supplierId} not found`); } return SupplierSchema.parse(result.Item); } async getSupplierByApimId(apimId: string): Promise { - const result = await this.ddbClient.send(new QueryCommand({ - TableName: this.config.suppliersTableName, - IndexName: 'supplier-apim-index', - KeyConditionExpression: 'apimId = :apimId', - ExpressionAttributeValues: { - ':apimId': apimId - }, - })); + const result = await this.ddbClient.send( + new QueryCommand({ + TableName: this.config.suppliersTableName, + IndexName: "supplier-apim-index", + KeyConditionExpression: "apimId = :apimId", + ExpressionAttributeValues: { + ":apimId": apimId, + }, + }), + ); - if(result.Count && result.Count > 1) { - throw new Error(`Multiple suppliers found with apimId ${apimId}`); - } + if (result.Count && result.Count > 1) { + throw new Error(`Multiple suppliers found with apimId ${apimId}`); + } - if(result.Count === 0 || !result.Items) { - throw new Error(`Supplier with apimId ${apimId} not found`); - } + if (result.Count === 0 || !result.Items) { + throw new Error(`Supplier with apimId ${apimId} not found`); + } - return SupplierSchema.parse(result.Items[0]); + return SupplierSchema.parse(result.Items[0]); } -}; +} diff --git a/internal/datastore/src/types.ts b/internal/datastore/src/types.ts index 2b8e0d36f..65b6df88f 100644 --- a/internal/datastore/src/types.ts +++ b/internal/datastore/src/types.ts @@ -1,22 +1,33 @@ -import { z } from 'zod'; -import { idRef } from '@internal/helpers'; +import { z } from "zod"; +import { idRef } from "@internal/helpers"; -export const SupplierStatus = z.enum(['ENABLED', 'DISABLED']); +export const SupplierStatus = z.enum(["ENABLED", "DISABLED"]); -export const SupplierSchema = z.object({ - id: z.string(), - name: z.string(), - apimId: z.string(), - status: SupplierStatus, - updatedAt: z.string(), -}).describe('Supplier'); +export const SupplierSchema = z + .object({ + id: z.string(), + name: z.string(), + apimId: z.string(), + status: SupplierStatus, + updatedAt: z.string(), + }) + .describe("Supplier"); export type Supplier = z.infer; export const LetterStatus = z.enum([ - 'PENDING', 'ACCEPTED', 'REJECTED', 'PRINTED', - 'ENCLOSED', 'CANCELLED', 'DISPATCHED', 'FAILED', - 'RETURNED', 'FORWARDED', 'DELIVERED']); + "PENDING", + "ACCEPTED", + "REJECTED", + "PRINTED", + "ENCLOSED", + "CANCELLED", + "DISPATCHED", + "FAILED", + "RETURNED", + "FORWARDED", + "DELIVERED", +]); export type LetterStatusType = z.infer; @@ -26,18 +37,18 @@ export const LetterSchemaBase = z.object({ specificationId: z.string(), groupId: z.string(), reasonCode: z.string().optional(), - reasonText: z.string().optional() + reasonText: z.string().optional(), }); export const LetterSchema = LetterSchemaBase.extend({ - supplierId: idRef(SupplierSchema, 'id'), + supplierId: idRef(SupplierSchema, "id"), url: z.url(), createdAt: z.string(), updatedAt: z.string(), - supplierStatus: z.string().describe('Secondary index PK'), - supplierStatusSk: z.string().describe('Secondary index SK'), + supplierStatus: z.string().describe("Secondary index PK"), + supplierStatusSk: z.string().describe("Secondary index SK"), ttl: z.int(), -}).describe('Letter'); +}).describe("Letter"); /** * Letter is the type used for storing letters in the database. @@ -54,15 +65,15 @@ export const MISchemaBase = z.object({ quantity: z.number(), specificationId: z.string().optional(), groupId: z.string().optional(), - stockRemaining: z.number().optional() + stockRemaining: z.number().optional(), }); export const MISchema = MISchemaBase.extend({ - supplierId: idRef(SupplierSchema, 'id'), + supplierId: idRef(SupplierSchema, "id"), createdAt: z.string(), updatedAt: z.string(), ttl: z.int(), -}).describe('MI'); +}).describe("MI"); export type MI = z.infer; export type MIBase = z.infer; diff --git a/internal/events/package.json b/internal/events/package.json index cd87efce1..f21db1654 100644 --- a/internal/events/package.json +++ b/internal/events/package.json @@ -46,8 +46,9 @@ "pretest:unit": "npm run gen:jsonschema", "start": "node dist/index.js", "test": "npm run test:unit", - "test:unit": "jest" + "test:unit": "jest", + "typecheck": "tsc --noEmit" }, "types": "dist/index.d.ts", - "version": "1.0.2" + "version": "1.0.3" } diff --git a/internal/helpers/src/domain-base.ts b/internal/events/src/domain/domain-base.ts similarity index 87% rename from internal/helpers/src/domain-base.ts rename to internal/events/src/domain/domain-base.ts index c738220a3..1e8dfd43e 100644 --- a/internal/helpers/src/domain-base.ts +++ b/internal/events/src/domain/domain-base.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export function DomainBase( +export default function DomainBase( type: T, ): z.ZodObject<{ domainId: z.core.$ZodBranded }> { const idType = z diff --git a/internal/events/src/domain/letter.ts b/internal/events/src/domain/letter.ts index e06aa3389..547465915 100644 --- a/internal/events/src/domain/letter.ts +++ b/internal/events/src/domain/letter.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { DomainBase } from "@internal/helpers"; +import DomainBase from "./domain-base"; /** * Status values for letters in the supplier-api domain diff --git a/internal/events/src/events/__tests__/event-envelope.test.ts b/internal/events/src/events/__tests__/event-envelope.test.ts index b3cd8e4b0..0ea46e222 100644 --- a/internal/events/src/events/__tests__/event-envelope.test.ts +++ b/internal/events/src/events/__tests__/event-envelope.test.ts @@ -272,9 +272,7 @@ describe("EventEnvelope schema validation", () => { const result = $EnvelopeWithPrefix.safeParse(envelope); expect(result.success).toBe(false); - if (!result.success) { - expect(result.error.issues[0].path).toContain("subject"); - } + expect(result?.error?.issues[0].path).toContain("subject"); }); it("should reject subject with incomplete prefix", () => { diff --git a/internal/events/src/events/event-envelope.ts b/internal/events/src/events/event-envelope.ts index aeccd2d60..a040fe9da 100644 --- a/internal/events/src/events/event-envelope.ts +++ b/internal/events/src/events/event-envelope.ts @@ -76,11 +76,7 @@ export function EventEnvelope( subject: z .string() - .regex( - new RegExp( - `^${subjectPrefixRegex}${resourceName}/[a-z0-9-]+$`, - ), - ) + .regex(new RegExp(`^${subjectPrefixRegex}${resourceName}/[a-z0-9-]+$`)) .meta({ title: "Event Subject", description: diff --git a/internal/events/src/index.ts b/internal/events/src/index.ts index 03324bf20..c4255d26c 100644 --- a/internal/events/src/index.ts +++ b/internal/events/src/index.ts @@ -1,5 +1,6 @@ export * from "./domain/letter"; export * from "./domain/mi"; +export { default as DomainBase } from "./domain/domain-base"; export * from "./events/event-envelope"; export * from "./events/letter-events"; export * from "./events/mi-events"; diff --git a/internal/events/tsconfig.json b/internal/events/tsconfig.json index 3426c90e3..fa5c2949b 100644 --- a/internal/events/tsconfig.json +++ b/internal/events/tsconfig.json @@ -14,7 +14,6 @@ "extends": "../../tsconfig.base.json", "include": [ "src/**/*", - "jest.config.ts", "package.json" ] } diff --git a/internal/helpers/jest.config.ts b/internal/helpers/jest.config.ts index f406a4bb3..ff7aef382 100644 --- a/internal/helpers/jest.config.ts +++ b/internal/helpers/jest.config.ts @@ -1,11 +1,14 @@ export default { preset: "ts-jest", testEnvironment: "node", - testMatch: ["**/src/__tests__/**/*.ts?(x)", "**/src/?(*.)+(spec|test).ts?(x)"], + testMatch: [ + "**/src/__tests__/**/*.ts?(x)", + "**/src/?(*.)+(spec|test).ts?(x)", + ], collectCoverageFrom: ["src/**/*.ts"], coveragePathIgnorePatterns: ["/node_modules/", "__tests__"], testPathIgnorePatterns: ["/node_modules/", "/dist/"], transform: { - "^.+\\.tsx?$": ["ts-jest", { }] - } + "^.+\\.tsx?$": ["ts-jest", {}], + }, }; diff --git a/internal/helpers/src/__tests__/id-ref.test.ts b/internal/helpers/src/__tests__/id-ref.test.ts index 3204b3057..449e7798c 100644 --- a/internal/helpers/src/__tests__/id-ref.test.ts +++ b/internal/helpers/src/__tests__/id-ref.test.ts @@ -12,7 +12,9 @@ describe("idRef", () => { // Should validate UUIDs like the original schema's domainId expect(() => refField.parse("not-a-uuid")).toThrow(); - expect(() => refField.parse("123e4567-e89b-12d3-a456-426614174000")).not.toThrow(); + expect(() => + refField.parse("123e4567-e89b-12d3-a456-426614174000"), + ).not.toThrow(); }); it("should add reference metadata", () => { @@ -20,10 +22,12 @@ describe("idRef", () => { expect(refField).toBeDefined(); expect(z.globalRegistry.has(refField)).toBe(true); - expect(z.globalRegistry.get(refField)).toEqual(expect.objectContaining({ - title: "TestEntity ID Reference", - description: "Reference to a TestEntity by its unique identifier", - })); + expect(z.globalRegistry.get(refField)).toEqual( + expect.objectContaining({ + title: "TestEntity ID Reference", + description: "Reference to a TestEntity by its unique identifier", + }), + ); }); it("should use custom ID field name when provided", () => { diff --git a/internal/helpers/src/environment.ts b/internal/helpers/src/environment.ts index ce997d4f3..440e9164a 100644 --- a/internal/helpers/src/environment.ts +++ b/internal/helpers/src/environment.ts @@ -1,11 +1,13 @@ -import { z } from 'zod'; +import { z } from "zod"; /** * Deployment / execution environment identifier. * Intentionally liberal; constrain in callers as needed. */ -export const $Environment = z.string().meta({ - title: 'Environment', - description: 'The environment in which the configuration has effect', - examples: ['dev', 'int', 'prod'], +const $Environment = z.string().meta({ + title: "Environment", + description: "The environment in which the configuration has effect", + examples: ["dev", "int", "prod"], }); + +export default $Environment; diff --git a/internal/helpers/src/id-ref.ts b/internal/helpers/src/id-ref.ts index e757725de..9a3973bfc 100644 --- a/internal/helpers/src/id-ref.ts +++ b/internal/helpers/src/id-ref.ts @@ -24,15 +24,19 @@ import { z } from "zod"; // Overload for when a specific ID field is provided export function idRef< T extends z.ZodObject>, - K extends keyof T["shape"] & string + K extends keyof T["shape"] & string, >(schema: T, idFieldName: K, entityName?: string): T["shape"][K]; // Overload for when using the default "domainId" field export function idRef< T extends z.ZodObject> & { - shape: { domainId: z.ZodTypeAny } - } ->(schema: T, idFieldName?: undefined, entityName?: string): T["shape"]["domainId"]; + shape: { domainId: z.ZodTypeAny }; + }, +>( + schema: T, + idFieldName?: undefined, + entityName?: string, +): T["shape"]["domainId"]; // Implementation export function idRef< diff --git a/internal/helpers/src/index.ts b/internal/helpers/src/index.ts index 9a79b0387..cc2c27e58 100644 --- a/internal/helpers/src/index.ts +++ b/internal/helpers/src/index.ts @@ -4,7 +4,6 @@ */ // Export all helpers -export * from './version'; -export * from './environment'; +export * from "./version"; +export { default as $Environment } from "./environment"; export * from "./id-ref"; -export * from "./domain-base"; diff --git a/internal/helpers/src/version.ts b/internal/helpers/src/version.ts index ab1f300aa..41db690fb 100644 --- a/internal/helpers/src/version.ts +++ b/internal/helpers/src/version.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import { z } from "zod"; /** * Semantic version (major.minor.patch) with numeric segments only. @@ -6,7 +6,7 @@ import { z } from 'zod'; */ export const $Version = z .string() - .regex(/^[0-9]+\.[0-9]+\.[0-9]+$/) - .brand('Version'); + .regex(/^\d+\.\d+\.\d+$/) + .brand("Version"); export type Version = z.infer; diff --git a/lambdas/api-handler/src/handlers/get-status.ts b/lambdas/api-handler/src/handlers/get-status.ts index 81bbf9038..6af8740ca 100644 --- a/lambdas/api-handler/src/handlers/get-status.ts +++ b/lambdas/api-handler/src/handlers/get-status.ts @@ -1,7 +1,6 @@ import { APIGatewayProxyHandler } from "aws-lambda"; import { Deps } from "../config/deps"; import { ListBucketsCommand, S3Client } from "@aws-sdk/client-s3"; -import { mapErrorToResponse } from "../mappers/error-mapper"; export function createGetStatusHandler(deps: Deps): APIGatewayProxyHandler { diff --git a/lambdas/api-handler/src/mappers/error-mapper.ts b/lambdas/api-handler/src/mappers/error-mapper.ts index d2fc98661..fae49c5ab 100644 --- a/lambdas/api-handler/src/mappers/error-mapper.ts +++ b/lambdas/api-handler/src/mappers/error-mapper.ts @@ -1,7 +1,7 @@ import { APIGatewayProxyResult } from "aws-lambda"; import { NotFoundError, ValidationError } from "../errors"; import { buildApiError, ApiErrorCode, ApiErrorTitle, ApiError, ApiErrorStatus, ErrorResponse } from "../contracts/errors"; -import { v4 as uuid } from 'uuid'; +import { randomUUID } from 'node:crypto'; import { Logger } from "pino"; export function processError(error: unknown, correlationId: string | undefined, logger: Logger): APIGatewayProxyResult @@ -34,7 +34,7 @@ function mapToErrorResponse(apiError: ApiError) } function mapToApiError(code: ApiErrorCode, detail: string, correlationId: string | undefined): ApiError { - const id = correlationId ? correlationId : uuid(); + const id = correlationId ? correlationId : randomUUID(); return buildApiError({ id, code, diff --git a/package-lock.json b/package-lock.json index dee32e91e..326f15e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -78,7 +78,8 @@ "@aws-sdk/lib-dynamodb": "^3.858.0", "@internal/helpers": "*", "pino": "^9.7.0", - "zod": "^4.1.11" + "zod": "^4.1.11", + "zod-mermaid": "^1.0.9" }, "devDependencies": { "@stylistic/eslint-plugin": "^3.1.0", @@ -93,13 +94,12 @@ "testcontainers": "^11.4.0", "ts-jest": "^29.4.0", "ts-node": "^10.9.2", - "typescript": "^5.9.3", - "zod-mermaid": "^1.0.9" + "typescript": "^5.9.3" } }, "internal/events": { "name": "@nhsdigital/nhs-notify-event-schemas-supplier-api", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "dependencies": { "@asyncapi/bundler": "^0.6.4", @@ -25473,7 +25473,6 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/zod-mermaid/-/zod-mermaid-1.0.9.tgz", "integrity": "sha512-H3hbSoScS/1tdlKKGTzb1gQRqnlsxF3QoD4dRAqFSAfVC8/8lGDdUx+YxiXzIB6HPo1L4xjNJOPSwXxCyFTCFQ==", - "dev": true, "license": "MIT", "dependencies": { "zod": "^4.0.5" diff --git a/scripts/utilities/supplier-data/src/cli/index.ts b/scripts/utilities/supplier-data/src/cli/index.ts index 4c37156a8..3efab2790 100644 --- a/scripts/utilities/supplier-data/src/cli/index.ts +++ b/scripts/utilities/supplier-data/src/cli/index.ts @@ -22,11 +22,11 @@ async function main() { }, "name": { type: "string", - demandOption: false, + demandOption: true, }, "apimId": { type: "string", - demandOption: false, + demandOption: true, }, status: { type: "string", diff --git a/tests/component-tests/apiGateway-tests/testCases/UpdateLetterStatus.ts b/tests/component-tests/apiGateway-tests/testCases/updateLetterStatus.ts similarity index 100% rename from tests/component-tests/apiGateway-tests/testCases/UpdateLetterStatus.ts rename to tests/component-tests/apiGateway-tests/testCases/updateLetterStatus.ts diff --git a/tests/package.json b/tests/package.json index 338404e4f..4840c1f72 100644 --- a/tests/package.json +++ b/tests/package.json @@ -28,9 +28,12 @@ "name": "nhs-notify-supplier-api-tests", "scripts": { "clean": "rimraf $(pwd)/target", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "test:component": "playwright test --config=config/main.config.ts --max-failures=10 --project=component-tests", "test:sandbox": "playwright test --config=config/sandbox.config.ts --max-failures=10 --project=sandbox", - "test:unit": "echo \"No unit tests for tests package\"" + "test:unit": "echo \"No unit tests for tests package\"", + "typecheck": "tsc --noEmit" }, "version": "1.0.0" } diff --git a/tests/sandbox/testCases/updateLetterStatus_testCases.ts b/tests/sandbox/testCases/updateLetterStatus_testCases.ts index a22f36759..1932aac20 100644 --- a/tests/sandbox/testCases/updateLetterStatus_testCases.ts +++ b/tests/sandbox/testCases/updateLetterStatus_testCases.ts @@ -1,5 +1,5 @@ -import { PatchMessageRequestBody, PatchMessageResponseBody } from '../../component-tests/apiGateway-tests/testCases/UpdateLetterStatus'; +import { PatchMessageRequestBody, PatchMessageResponseBody } from '../../component-tests/apiGateway-tests/testCases/updateLetterStatus'; import { RequestSandBoxHeaders, sandBoxHeader } from '../../constants/request_headers'; import { ErrorMessageBody } from '../../helpers/commonTypes'; import { SandboxErrorResponse } from './getListOfLetters_testCases'; diff --git a/tests/tsconfig.json b/tests/tsconfig.json new file mode 100644 index 000000000..684d10fab --- /dev/null +++ b/tests/tsconfig.json @@ -0,0 +1,4 @@ +{ + "compilerOptions": {}, + "extends": "../tsconfig.base.json" +}