Skip to content
Open
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
22 changes: 18 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 62 additions & 0 deletions scripts/utilities/letter-status-fixer/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Config } from "jest";

export const baseJestConfig: Config = {
preset: "ts-jest",

// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,

// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,

// The directory where Jest should output its coverage files
coverageDirectory: "./.reports/unit/coverage",

// Indicates which provider should be used to instrument code for coverage
coverageProvider: "babel",

coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: -10,
},
},

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",
[
"jest-html-reporter",
{
pageTitle: "Test Report",
outputPath: "./.reports/unit/test-report.html",
includeFailureMsg: true,
},
],
],

// The test environment that will be used for testing
testEnvironment: "jsdom",
};

const utilsJestConfig = {
...baseJestConfig,

testEnvironment: "node",

coveragePathIgnorePatterns: [
...(baseJestConfig.coveragePathIgnorePatterns ?? []),
"cli/index.ts",
"helpers/s3_helpers.ts",
"letter-repo-factory.ts",
],
};

export default utilsJestConfig;
21 changes: 21 additions & 0 deletions scripts/utilities/letter-status-fixer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"dependencies": {
"@aws-sdk/client-dynamodb": "^3.984.0",
"@aws-sdk/lib-dynamodb": "^3.1008.0",
"@jest/globals": "^30.2.0",
"pino": "^10.3.0",
"yargs": "^17.7.2"
},
"main": "src/cli/index.ts",
"name": "letter-status-fixer",
"private": true,
"scripts": {
"fix-status": "tsx src/cli/index.ts fix-status",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"test:unit": "jest",
"typecheck": "tsc --noEmit"
},
"type": "module",
"version": "0.1.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
import * as fs from "node:fs";

// Import after mocks
import { expect, jest } from "@jest/globals";
import { updateFailedLetters } from "../cli";

jest.mock("@aws-sdk/lib-dynamodb");
jest.mock("fs");

const mockSend = jest.fn();
const mockLog = { info: jest.fn(), error: jest.fn() };

jest.mock("../infrastructure/letters-repo-factory", () => ({
createLetterDocClient: () => ({
docClient: { send: mockSend },
log: mockLog,
config: {
lettersTableName: "test-table",
supplierStatusIndex: "test-index",
},
}),
}));

describe("updateFailedLetters", () => {
const environment = "test-env";
const supplierId = "SUP123";
const status = "PENDING";
// logFile and failuresFile are generated dynamically in the implementation, so we check for .log in assertions

let fsMock: jest.Mocked<typeof fs>;
beforeEach(() => {
jest.clearAllMocks();
fsMock = fs as jest.Mocked<typeof fs>;
fsMock.appendFileSync.mockClear();
fsMock.writeFileSync.mockClear();
});

it("updates all matching letters and logs IDs", async () => {
mockSend
.mockImplementationOnce(() => ({
Items: [
{ id: "id1", groupId: "x".repeat(101) },
{ id: "id2", groupId: "y".repeat(102) },
],
LastEvaluatedKey: undefined,
}))
.mockImplementation(() => ({})); // For UpdateCommand

await updateFailedLetters(environment, supplierId, status, false);

expect(mockSend).toHaveBeenCalledWith(expect.any(QueryCommand));
expect(mockSend).toHaveBeenCalledWith(expect.any(UpdateCommand));
expect(fs.appendFileSync).toHaveBeenCalledWith(
expect.stringMatching(/updated-letters-\d+\.log/),
expect.stringContaining("id1"),
"utf8",
);
expect(fs.appendFileSync).toHaveBeenCalledWith(
expect.stringMatching(/updated-letters-\d+\.log/),
expect.stringContaining("id2"),
"utf8",
);
expect(mockLog.info).toHaveBeenCalledWith(
expect.stringContaining("Updated 2 letters to FAILED"),
);
});

it("logs failed updates to failures file", async () => {
mockSend
.mockImplementationOnce(() => ({
Items: [{ id: "id3", groupId: "z".repeat(120) }],
LastEvaluatedKey: undefined,
}))
.mockImplementationOnce(() => {
throw new Error("fail");
});

await updateFailedLetters(environment, supplierId, status, false);

expect(fs.appendFileSync).toHaveBeenCalledWith(
expect.stringMatching(/failed-letters-\d+\.log/),
expect.stringContaining("id3"),
"utf8",
);
expect(mockLog.error).toHaveBeenCalledWith(
expect.anything(),
expect.stringContaining("Failed to update letter id3"),
);
});
});
110 changes: 110 additions & 0 deletions scripts/utilities/letter-status-fixer/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { QueryCommand, UpdateCommand } from "@aws-sdk/lib-dynamodb";
import fs from "node:fs";
import path from "node:path";
import { createLetterDocClient } from "../infrastructure/letters-repo-factory";

const commandOptions = {
environment: { type: "string" as const, demandOption: true },
supplierId: { type: "string" as const, demandOption: true },
status: { type: "string" as const, demandOption: true },
dryrun: { type: "boolean" as const, demandOption: false, default: true },
};

/* eslint-disable import-x/prefer-default-export */
export async function updateFailedLetters(
environment: string,
supplierId: string,
status: string,
dryrun: boolean,
) {
const { config, docClient, log } = createLetterDocClient(environment);
const compoundKey = `${supplierId}#${status}`;
let lastKey: Record<string, any> | undefined;
let updatedCount = 0;
let failedCount = 0;
const dryrunSuffix = dryrun ? "-dryrun" : "";
const logFile = path.resolve(
process.cwd(),
`updated-letters-${Date.now()}${dryrunSuffix}.log`,
);
const failuresFile = path.resolve(
process.cwd(),
`failed-letters-${Date.now()}${dryrunSuffix}.log`,
);

do {
Comment thread
masl2 marked this conversation as resolved.
const queryCmd = new QueryCommand({
TableName: config.lettersTableName,
IndexName: config.supplierStatusIndex,
KeyConditionExpression: "supplierStatus = :ss",
FilterExpression: "attribute_exists(groupId) AND size(groupId) > :len",
ExpressionAttributeValues: { ":ss": compoundKey, ":len": 100 },
ExclusiveStartKey: lastKey,
});

log.info(queryCmd);

const result = await docClient.send(queryCmd);

log.info(
`Found ${result.Items?.length || 0} letters with supplierId ${supplierId} and status ${status} that have groupId longer than 100 characters.`,
);

for (const item of result.Items || []) {
try {
log.info(
`Updating letter ${item.id} (groupId length: ${item.groupId?.length}) to FAILED`,
);
const updateCmd = new UpdateCommand({
TableName: config.lettersTableName,
Key: { letterId: item.id },
UpdateExpression: "SET #status = :failed",
ExpressionAttributeNames: { "#status": "status" },
ExpressionAttributeValues: { ":failed": "FAILED" },
});
if (!dryrun) {
await docClient.send(updateCmd);
}
fs.appendFileSync(logFile, `${item.id}\n`, "utf8");
updatedCount += 1;
} catch (error) {
log.error({ err: error }, `Failed to update letter ${item.id}`);
fs.appendFileSync(failuresFile, `${item.id}\n`, "utf8");
failedCount += 1;
}
}
lastKey = result.LastEvaluatedKey;
} while (lastKey);

log.info(
`Updated ${updatedCount} letters to FAILED. IDs written to ${logFile}`,
);
log.info(`${failedCount} failed updates written to ${failuresFile}`);
}

async function main() {
await yargs(hideBin(process.argv))
.command(
"fix-status",
"Update letters with long groupId to FAILED",
commandOptions,
async (argv) => {
const environment = argv.environment as string;
const supplierId = argv.supplierId as string;
const status = argv.status as string;
const { dryrun } = argv;
await updateFailedLetters(environment, supplierId, status, dryrun);
},
)
.demandCommand(1)
.parse();
}

if (require.main === module) {
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
import pino from "pino";

/* eslint-disable import-x/prefer-default-export */
export function createLetterDocClient(environment: string) {
const ddbClient = new DynamoDBClient({});
const docClient = DynamoDBDocumentClient.from(ddbClient);
const log = pino();
const config = {
lettersTableName:
process.env.LETTERS_TABLE || `nhs-${environment}-supapi-letters`,
supplierStatusIndex:
process.env.SUPPLIER_STATUS_INDEX || "supplierStatus-index",
};
return { docClient, log, config };
}
8 changes: 8 additions & 0 deletions scripts/utilities/letter-status-fixer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"compilerOptions": {},
"extends": "../../../tsconfig.base.json",
"include": [
"src/**/*",
"jest.config.ts"
]
}
Loading