Skip to content

Commit 1f2a4bd

Browse files
committed
Add integration tests for DynamoDB Local and support custom endpoint configuration
1 parent daecddc commit 1f2a4bd

7 files changed

Lines changed: 323 additions & 1 deletion

File tree

.github/workflows/stage-2-test.yaml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,3 +152,19 @@ jobs:
152152
sonar_organisation_key: "${{ vars.SONAR_ORGANISATION_KEY }}"
153153
sonar_project_key: "${{ vars.SONAR_PROJECT_KEY }}"
154154
sonar_token: "${{ secrets.SONAR_TOKEN }}"
155+
test-integration:
156+
name: "Integration tests"
157+
runs-on: ubuntu-latest
158+
timeout-minutes: 10
159+
steps:
160+
- name: "Checkout code"
161+
uses: actions/checkout@v4
162+
- name: "Repo setup"
163+
run: |
164+
npm ci
165+
- name: "Generate dependencies"
166+
run: |
167+
npm run generate-dependencies --workspaces --if-present
168+
- name: "Run workspace integration tests"
169+
run: |
170+
npm run test:integration --workspaces --if-present

packages/ddb-publisher/jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const config: Config = {
4141

4242
testEnvironment: "node",
4343
testMatch: ["**/__tests__/**/*.ts", "**/?(*.)+(spec|test).ts"],
44+
testPathIgnorePatterns: ["/__integration__/"],
4445
moduleFileExtensions: ["ts", "js", "json", "node"],
4546

4647
transform: {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Config } from "jest";
2+
3+
const config: Config = {
4+
preset: "ts-jest",
5+
clearMocks: true,
6+
collectCoverage: false,
7+
testEnvironment: "node",
8+
testMatch: ["**/__integration__/**/*.test.ts"],
9+
moduleFileExtensions: ["ts", "js", "json", "node"],
10+
testTimeout: 120_000,
11+
transform: {
12+
"^.+\\.ts$": [
13+
"ts-jest",
14+
{
15+
tsconfig: {
16+
module: "CommonJS",
17+
},
18+
},
19+
],
20+
},
21+
};
22+
23+
export default config;

packages/ddb-publisher/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"lint": "eslint .",
2828
"lint:fix": "eslint . --fix",
2929
"start": "tsx src/cli.ts",
30+
"test:integration": "jest --config jest.integration.config.ts --runInBand",
3031
"test:unit": "jest",
3132
"typecheck": "tsc --noEmit"
3233
},
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
2+
import { tmpdir } from "node:os";
3+
import { join, resolve } from "node:path";
4+
import { execFile } from "node:child_process";
5+
import { promisify } from "node:util";
6+
7+
import {
8+
CreateTableCommand,
9+
DynamoDBClient,
10+
ListTablesCommand,
11+
ScanCommand,
12+
waitUntilTableExists,
13+
} from "@aws-sdk/client-dynamodb";
14+
15+
const execFileAsync = promisify(execFile);
16+
17+
const repoRoot = resolve(__dirname, "../../../..");
18+
19+
let containerName = "";
20+
let endpointUrl = "";
21+
let dockerAvailable = true;
22+
23+
async function run(
24+
command: string,
25+
args: string[],
26+
cwd = repoRoot,
27+
env: NodeJS.ProcessEnv = process.env,
28+
): Promise<{ stdout: string; stderr: string }> {
29+
return execFileAsync(command, args, {
30+
cwd,
31+
env,
32+
});
33+
}
34+
35+
async function startDdbLocal(): Promise<void> {
36+
containerName = `ddb-publisher-it-${Date.now()}-${Math.floor(Math.random() * 10_000)}`;
37+
38+
try {
39+
await run("docker", ["version"]);
40+
} catch {
41+
dockerAvailable = false;
42+
return;
43+
}
44+
45+
await run("docker", [
46+
"run",
47+
"-d",
48+
"--name",
49+
containerName,
50+
"-p",
51+
"0:8000",
52+
"amazon/dynamodb-local:latest",
53+
"-jar",
54+
"DynamoDBLocal.jar",
55+
"-inMemory",
56+
"-sharedDb",
57+
]);
58+
59+
const portResult = await run("docker", ["port", containerName, "8000/tcp"]);
60+
const mapping = portResult.stdout.trim();
61+
const hostPort = mapping.split(":").at(-1);
62+
63+
if (!hostPort) {
64+
throw new Error(`Unable to parse docker port mapping from '${mapping}'.`);
65+
}
66+
67+
endpointUrl = `http://127.0.0.1:${hostPort}`;
68+
69+
const client = new DynamoDBClient({
70+
endpoint: endpointUrl,
71+
region: "eu-west-2",
72+
credentials: {
73+
accessKeyId: "local",
74+
secretAccessKey: "local",
75+
},
76+
});
77+
78+
// Wait for local DynamoDB to be ready before issuing create-table.
79+
for (let attempt = 0; attempt < 30; attempt += 1) {
80+
try {
81+
await client.send(new ListTablesCommand({ Limit: 1 }));
82+
break;
83+
} catch (error) {
84+
if (attempt === 29) {
85+
throw error;
86+
}
87+
88+
await new Promise((resolveWait) => {
89+
setTimeout(resolveWait, 250);
90+
});
91+
}
92+
}
93+
94+
await client.send(
95+
new CreateTableCommand({
96+
TableName: "supplier-config-it",
97+
BillingMode: "PAY_PER_REQUEST",
98+
KeySchema: [
99+
{ AttributeName: "pk", KeyType: "HASH" },
100+
{ AttributeName: "sk", KeyType: "RANGE" },
101+
],
102+
AttributeDefinitions: [
103+
{ AttributeName: "pk", AttributeType: "S" },
104+
{ AttributeName: "sk", AttributeType: "S" },
105+
],
106+
}),
107+
);
108+
109+
await waitUntilTableExists(
110+
{ client, maxWaitTime: 60 },
111+
{ TableName: "supplier-config-it" },
112+
);
113+
}
114+
115+
async function stopDdbLocal(): Promise<void> {
116+
if (!dockerAvailable || !containerName) return;
117+
118+
try {
119+
await run("docker", ["rm", "-f", containerName]);
120+
} catch {
121+
// Best-effort cleanup.
122+
}
123+
}
124+
125+
function mockPackSpecification(): Record<string, unknown> {
126+
return {
127+
id: "bau-standard-c5",
128+
name: "BAU Standard Letter C5",
129+
status: "PROD",
130+
version: 1,
131+
createdAt: "2023-01-01T00:00:00Z",
132+
updatedAt: "2023-01-01T00:00:00Z",
133+
billingId: "BILLING-BAU-C5-001",
134+
constraints: {
135+
sheets: {
136+
value: 5,
137+
operator: "LESS_THAN_OR_EQUAL",
138+
},
139+
},
140+
postage: {
141+
id: "ECONOMY",
142+
size: "STANDARD",
143+
deliveryDays: 3,
144+
},
145+
assembly: {
146+
envelopeId: "envelope-nhs-c5-economy",
147+
printColour: "BLACK",
148+
},
149+
};
150+
}
151+
152+
describe("publish action integration (DynamoDB Local)", () => {
153+
beforeAll(async () => {
154+
await startDdbLocal();
155+
});
156+
157+
afterAll(async () => {
158+
await stopDdbLocal();
159+
});
160+
161+
it("publishes records via the bundled action runtime into local DynamoDB", async () => {
162+
if (!dockerAvailable) {
163+
// eslint-disable-next-line no-console
164+
console.warn("Docker is not available; skipping DynamoDB Local integration test.");
165+
return;
166+
}
167+
168+
const tempRoot = await mkdtemp(join(tmpdir(), "ddb-publish-config-"));
169+
170+
try {
171+
const entityDir = join(tempRoot, "pack-specification");
172+
await mkdir(entityDir, { recursive: true });
173+
await writeFile(
174+
join(entityDir, "bau-standard-c5.json"),
175+
JSON.stringify(mockPackSpecification(), null, 2),
176+
"utf8",
177+
);
178+
179+
// Build the same bundle that the GitHub Action downloads and executes.
180+
await run("npm", [
181+
"run",
182+
"bundle:release",
183+
"--workspace",
184+
"@supplier-config/ddb-publisher",
185+
]);
186+
187+
const bundlePath = join(
188+
repoRoot,
189+
"packages/ddb-publisher/artifacts/ddb-publish/index.cjs",
190+
);
191+
192+
await run(
193+
"node",
194+
[
195+
bundlePath,
196+
"--source",
197+
tempRoot,
198+
"--env",
199+
"draft",
200+
"--table",
201+
"supplier-config-it",
202+
],
203+
repoRoot,
204+
{
205+
...process.env,
206+
SUPPLIER_CONFIG_DDB_ENDPOINT_URL: endpointUrl,
207+
AWS_ACCESS_KEY_ID: "local",
208+
AWS_SECRET_ACCESS_KEY: "local",
209+
AWS_REGION: "eu-west-2",
210+
},
211+
);
212+
213+
const verifyClient = new DynamoDBClient({
214+
endpoint: endpointUrl,
215+
region: "eu-west-2",
216+
credentials: {
217+
accessKeyId: "local",
218+
secretAccessKey: "local",
219+
},
220+
});
221+
222+
const scanned = await verifyClient.send(
223+
new ScanCommand({
224+
TableName: "supplier-config-it",
225+
}),
226+
);
227+
228+
expect(scanned.Items).toBeDefined();
229+
expect(scanned.Items).toHaveLength(1);
230+
231+
const [item] = scanned.Items ?? [];
232+
expect(item?.pk?.S).toBe("ENTITY#pack-specification");
233+
expect(item?.sk?.S).toBe("ID#bau-standard-c5");
234+
expect(item?.env?.S).toBe("draft");
235+
expect(item?.entity?.S).toBe("pack-specification");
236+
} finally {
237+
await rm(tempRoot, { recursive: true, force: true });
238+
}
239+
});
240+
});

packages/ddb-publisher/src/__tests__/run.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ const publish = jest.requireMock("../ddb/publish") as unknown as {
5151
publishRecords: jest.Mock;
5252
};
5353

54+
const awsClient = jest.requireMock("@aws-sdk/client-dynamodb") as unknown as {
55+
DynamoDBClient: jest.Mock;
56+
};
57+
5458
describe("runPublisher", () => {
5559
it("should stop after validation when dryRun=true", async () => {
5660
const store: LoadedConfigStore = { rootPath: "/tmp", records: [] };
@@ -218,4 +222,33 @@ describe("runPublisher", () => {
218222

219223
writeSpy.mockRestore();
220224
});
225+
226+
it("should pass custom endpoint to DynamoDB client when env override is set", async () => {
227+
const store: LoadedConfigStore = {
228+
rootPath: "/tmp",
229+
records: [],
230+
};
231+
232+
fileStore.loadConfigStore.mockResolvedValue(store);
233+
fileStore.validateConfigStore.mockReturnValue({ ok: true, issues: [] });
234+
audit.auditBeforeLoad.mockResolvedValue({ blocking: [] });
235+
236+
process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL = "http://127.0.0.1:8000";
237+
238+
try {
239+
await runPublisher({
240+
sourcePath: "/tmp",
241+
env: "draft",
242+
tableName: "tbl",
243+
dryRun: false,
244+
force: false,
245+
});
246+
247+
expect(awsClient.DynamoDBClient).toHaveBeenCalledWith({
248+
endpoint: "http://127.0.0.1:8000",
249+
});
250+
} finally {
251+
delete process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL;
252+
}
253+
});
221254
});

packages/ddb-publisher/src/run.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,16 @@ async function runPublisher(plan: LoadPlan): Promise<void> {
7272
return;
7373
}
7474

75+
const endpointOverride = process.env.SUPPLIER_CONFIG_DDB_ENDPOINT_URL;
76+
77+
if (endpointOverride) {
78+
logStep(`Using custom DynamoDB endpoint '${endpointOverride}'.`);
79+
}
80+
7581
logStep("Initialising DynamoDB client...");
76-
const client = new DynamoDBClient({});
82+
const client = new DynamoDBClient({
83+
endpoint: endpointOverride,
84+
});
7785
const ddb = DynamoDBDocumentClient.from(client, {
7886
marshallOptions: {
7987
removeUndefinedValues: true,

0 commit comments

Comments
 (0)