test(ci): gate deployments on MariaDB and Redis integration checks
This commit is contained in:
1 parent
966a925614
commit
422be3ce2d
8 files changed
+1312
-4
No files matched your search
@@ -58,6 +58,9 @@ jobs:
|
||||
time pnpm test:coverage --maxWorkers=4
|
||||
fi
|
||||
|
||||
- name: MariaDB and Redis integration tests
|
||||
run: pnpm test:integration
|
||||
|
||||
# Compare against reviewed Linux references; updates are explicit.
|
||||
- name: Install UI test browser
|
||||
run: pnpm exec playwright install chromium
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Real database integration tests
|
||||
|
||||
Run `pnpm test:integration` on a Docker-capable host. Missing Docker or failed container setup fails the suite. CI runs this check before deployment.
|
||||
|
||||
Six tests exercise real MariaDB catalog transactions and Redis caching: migration CLI replay/status, committed bulk edits and undo history, complete rollback when the second audit insert fails, competing previews, cache expiry metadata and cross-key invalidation isolation.
|
||||
|
||||
Each execution starts disposable MariaDB11.4.5 and Redis7.4.2 containers with random exposed ports and generated passwords. No production URLs, volumes or credentials are used. The real migration CLI is copied beneath a temporary isolated fixture root so its environment loader cannot read the checkout environment file. Cleanup attempts all connections and containers even if a previous cleanup fails.
|
||||
|
||||
The emulator fixture contains minimal catalog and audit tables. Migrations0027-0029 are exercised; this is not certification of every historical emulator schema or migration. Operation/outbox tests are introduced with their implementation in the following increment.
|
||||
@@ -0,0 +1,308 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { copyFile, mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import type { RowDataPacket } from "mysql2/promise";
|
||||
import mysql from "mysql2/promise";
|
||||
import {
|
||||
GenericContainer,
|
||||
type StartedTestContainer,
|
||||
Wait,
|
||||
} from "testcontainers";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const exec = promisify(execFile);
|
||||
const migrations = [
|
||||
"0027_catalog_packages.sql",
|
||||
"0028_history_snapshots.sql",
|
||||
"0029_admin_table_views.sql",
|
||||
];
|
||||
let maria: StartedTestContainer | undefined;
|
||||
let redisContainer: StartedTestContainer | undefined;
|
||||
let connection: mysql.Connection | undefined;
|
||||
let appDb: typeof import("@/lib/db").db | undefined;
|
||||
let appRedis: typeof import("@/lib/redis").redis;
|
||||
let commands: typeof import("@/features/catalog/server/bulk-offers");
|
||||
let cache: typeof import("@/lib/cache");
|
||||
let migrationRoot: string | undefined;
|
||||
let databaseUrl: string;
|
||||
|
||||
async function rows(query: string) {
|
||||
if (!connection) throw Error("Integration database is not connected");
|
||||
const [result] = await connection.query<RowDataPacket[]>(query);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function migrate(...args: string[]) {
|
||||
if (!migrationRoot) throw Error("Migration fixture is not ready");
|
||||
// Only explicit test variables: the copied loader cannot see checkout .env files.
|
||||
return exec(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
pathToFileURL(resolve("node_modules/tsx/dist/loader.mjs")).href,
|
||||
join(migrationRoot, "scripts/apply-migrations.ts"),
|
||||
...args,
|
||||
],
|
||||
{
|
||||
cwd: migrationRoot,
|
||||
env: {
|
||||
PATH: process.env.PATH,
|
||||
SystemRoot: process.env.SystemRoot,
|
||||
DATABASE_URL: databaseUrl,
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
timeout: 30_000,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// No fixed names, ports, external URLs, shared volumes or container reuse.
|
||||
const databasePassword = randomUUID();
|
||||
const redisPassword = randomUUID();
|
||||
maria = await new GenericContainer("mariadb:11.4.5")
|
||||
.withEnvironment({
|
||||
MARIADB_ROOT_PASSWORD: randomUUID(),
|
||||
MARIADB_DATABASE: "integration",
|
||||
MARIADB_USER: "integration",
|
||||
MARIADB_PASSWORD: databasePassword,
|
||||
})
|
||||
.withExposedPorts(3306)
|
||||
.withHealthCheck({
|
||||
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
|
||||
interval: 1000,
|
||||
timeout: 5000,
|
||||
retries: 60,
|
||||
startPeriod: 1000,
|
||||
})
|
||||
.withWaitStrategy(Wait.forHealthCheck())
|
||||
.withStartupTimeout(120_000)
|
||||
.start();
|
||||
databaseUrl = `mysql://integration:${databasePassword}@${maria.getHost()}:${maria.getMappedPort(3306)}/integration`;
|
||||
connection = await mysql.createConnection(databaseUrl);
|
||||
redisContainer = await new GenericContainer("redis:7.4.2-alpine")
|
||||
.withCommand(["redis-server", "--requirepass", redisPassword])
|
||||
.withExposedPorts(6379)
|
||||
.withWaitStrategy(Wait.forLogMessage("Ready to accept connections"))
|
||||
.withStartupTimeout(60_000)
|
||||
.start();
|
||||
process.env.DATABASE_URL = databaseUrl;
|
||||
process.env.REDIS_URL = `redis://:${redisPassword}@${redisContainer.getHost()}:${redisContainer.getMappedPort(6379)}/0`;
|
||||
delete process.env.SKIP_ENV_VALIDATION;
|
||||
Object.assign(process.env, { NODE_ENV: "test" });
|
||||
process.env.HOTEL_NAME = "Integration";
|
||||
|
||||
await connection.query(
|
||||
"CREATE TABLE catalog_pages (id INT PRIMARY KEY, caption VARCHAR(255) NOT NULL) ENGINE=InnoDB",
|
||||
);
|
||||
await connection.query(
|
||||
"CREATE TABLE catalog_items (id INT PRIMARY KEY, catalog_name VARCHAR(255) NOT NULL, page_id VARCHAR(25) NOT NULL, cost_credits INT NOT NULL, cost_points INT NOT NULL, points_type INT NOT NULL) ENGINE=InnoDB",
|
||||
);
|
||||
await connection.query(
|
||||
"CREATE TABLE admin_audit_log (id INT AUTO_INCREMENT PRIMARY KEY, user_id INT NOT NULL, action VARCHAR(191) NOT NULL DEFAULT '', target VARCHAR(191) NOT NULL DEFAULT '', target_id INT NULL, details TEXT NULL, `before` TEXT NULL, `after` TEXT NULL, diff TEXT NULL, ip_address VARCHAR(45) NULL, created_at VARCHAR(64) NOT NULL DEFAULT '', updated_at VARCHAR(64) NULL) ENGINE=InnoDB",
|
||||
);
|
||||
// The emulator owns core tables. Exercise the real CMS migration CLI over this baseline.
|
||||
migrationRoot = await mkdtemp(join(resolve("integration"), ".migration-"));
|
||||
await mkdir(join(migrationRoot, "scripts"));
|
||||
await mkdir(join(migrationRoot, "drizzle/migrations"), { recursive: true });
|
||||
for (const file of [
|
||||
"apply-migrations.ts",
|
||||
"load-env.ts",
|
||||
"db-url.ts",
|
||||
"sql-statements.ts",
|
||||
]) {
|
||||
await copyFile(
|
||||
resolve("scripts", file),
|
||||
join(migrationRoot, "scripts", file),
|
||||
);
|
||||
}
|
||||
for (const file of migrations)
|
||||
await copyFile(
|
||||
resolve("drizzle/migrations", file),
|
||||
join(migrationRoot, "drizzle/migrations", file),
|
||||
);
|
||||
await migrate();
|
||||
appDb = (await import("@/lib/db")).db;
|
||||
appRedis = (await import("@/lib/redis")).redis;
|
||||
if (!appRedis) throw Error("Redis must be enabled in integration tests");
|
||||
await appRedis.ping();
|
||||
commands = await import("@/features/catalog/server/bulk-offers");
|
||||
cache = await import("@/lib/cache");
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Settle every cleanup so a failed setup or close cannot leak the other container.
|
||||
const cleanup = await Promise.allSettled([
|
||||
appDb?.$client.end(),
|
||||
appRedis?.quit(),
|
||||
connection?.end(),
|
||||
]);
|
||||
const stops = await Promise.allSettled([
|
||||
maria?.stop(),
|
||||
redisContainer?.stop(),
|
||||
]);
|
||||
if (migrationRoot) {
|
||||
const target = resolve(migrationRoot);
|
||||
if (
|
||||
!target.startsWith(`${resolve("integration")}\\.migration-`) &&
|
||||
!target.startsWith(`${resolve("integration")}/.migration-`)
|
||||
)
|
||||
throw Error("Unsafe migration fixture path");
|
||||
await rm(target, { recursive: true, force: true });
|
||||
}
|
||||
for (const result of [...cleanup, ...stops])
|
||||
if (result.status === "rejected") throw result.reason;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
if (!connection) throw Error("Integration database is not connected");
|
||||
await connection.query("DROP TRIGGER IF EXISTS reject_second_history");
|
||||
await connection.query("DELETE FROM admin_audit_log");
|
||||
await connection.query("DELETE FROM catalog_items");
|
||||
await connection.query("DELETE FROM catalog_pages");
|
||||
await connection.query(
|
||||
"INSERT INTO catalog_pages VALUES (1, 'Original'), (2, 'Destination')",
|
||||
);
|
||||
await connection.query(
|
||||
"INSERT INTO catalog_items VALUES (1, 'Chair', '1', 10, 0, 0), (2, 'Table', '1', 20, 0, 0)",
|
||||
);
|
||||
});
|
||||
|
||||
const input = {
|
||||
ids: [1, 2],
|
||||
changes: { costCredits: { mode: "add" as const, value: 5 } },
|
||||
};
|
||||
|
||||
describe("MariaDB migrations and catalog transactions", () => {
|
||||
it("runs the actual migration CLI twice without duplicate tracking or lost data", async () => {
|
||||
const before = await rows("SELECT * FROM cms_migrations ORDER BY id");
|
||||
expect(before.map((row) => row.migration)).toEqual(
|
||||
migrations.map((file) => file.replace(/\.sql$/, "")),
|
||||
);
|
||||
await connection?.query(
|
||||
"INSERT INTO website_admin_table_views VALUES (1, '/admin/catalog', 'Saved', '{}')",
|
||||
);
|
||||
expect((await migrate()).stdout).toContain(
|
||||
"All migrations already applied",
|
||||
);
|
||||
expect(await rows("SELECT * FROM cms_migrations ORDER BY id")).toEqual(
|
||||
before,
|
||||
);
|
||||
expect(await rows("SELECT name FROM website_admin_table_views")).toEqual([
|
||||
{ name: "Saved" },
|
||||
]);
|
||||
expect((await migrate("--status")).stdout).toContain(
|
||||
"3/3 applied, 0 pending",
|
||||
);
|
||||
const columns = await rows(
|
||||
"SELECT COLUMN_NAME, DATA_TYPE FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='admin_audit_log' AND COLUMN_NAME IN ('before','after') ORDER BY COLUMN_NAME",
|
||||
);
|
||||
expect(columns.map((row) => row.DATA_TYPE)).toEqual([
|
||||
"mediumtext",
|
||||
"mediumtext",
|
||||
]);
|
||||
});
|
||||
|
||||
it("commits both offers and history, then restores them through the real undo command", async () => {
|
||||
const preview = await commands.previewBulkOffersCommand(input);
|
||||
const result = await commands.applyBulkOffersCommand(
|
||||
input,
|
||||
preview.fingerprint,
|
||||
7,
|
||||
);
|
||||
expect(result.changedCount).toBe(2);
|
||||
expect(result.historyIds).toHaveLength(2);
|
||||
expect(
|
||||
(await rows("SELECT cost_credits FROM catalog_items ORDER BY id")).map(
|
||||
(row) => row.cost_credits,
|
||||
),
|
||||
).toEqual([15, 25]);
|
||||
expect(
|
||||
await rows("SELECT target, action FROM admin_audit_log ORDER BY id"),
|
||||
).toEqual([
|
||||
{ target: "catalog_offer", action: "history_update" },
|
||||
{ target: "catalog_offer", action: "history_update" },
|
||||
]);
|
||||
await commands.undoBulkOffersCommand(result.historyIds, 7);
|
||||
expect(
|
||||
(await rows("SELECT cost_credits FROM catalog_items ORDER BY id")).map(
|
||||
(row) => row.cost_credits,
|
||||
),
|
||||
).toEqual([10, 20]);
|
||||
expect(await rows("SELECT id FROM admin_audit_log")).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("rolls back earlier offer updates and history when the second history insert fails", async () => {
|
||||
await connection?.query(
|
||||
"CREATE TRIGGER reject_second_history BEFORE INSERT ON admin_audit_log FOR EACH ROW BEGIN IF NEW.target_id=2 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='forced history failure'; END IF; END",
|
||||
);
|
||||
const preview = await commands.previewBulkOffersCommand(input);
|
||||
await expect(
|
||||
commands.applyBulkOffersCommand(input, preview.fingerprint, 7),
|
||||
).rejects.toThrow();
|
||||
expect(
|
||||
(await rows("SELECT cost_credits FROM catalog_items ORDER BY id")).map(
|
||||
(row) => row.cost_credits,
|
||||
),
|
||||
).toEqual([10, 20]);
|
||||
expect(await rows("SELECT id FROM admin_audit_log")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("serializes competing applications of one preview and rejects the stale contender", async () => {
|
||||
const preview = await commands.previewBulkOffersCommand(input);
|
||||
const results = await Promise.allSettled([
|
||||
commands.applyBulkOffersCommand(input, preview.fingerprint, 7),
|
||||
commands.applyBulkOffersCommand(input, preview.fingerprint, 8),
|
||||
]);
|
||||
expect(
|
||||
results.filter((result) => result.status === "fulfilled"),
|
||||
).toHaveLength(1);
|
||||
const rejected = results.find((result) => result.status === "rejected");
|
||||
expect(rejected?.status === "rejected" && rejected.reason.message).toMatch(
|
||||
/selection.*changed|preview/i,
|
||||
);
|
||||
expect(
|
||||
(await rows("SELECT cost_credits FROM catalog_items ORDER BY id")).map(
|
||||
(row) => row.cost_credits,
|
||||
),
|
||||
).toEqual([15, 25]);
|
||||
expect(await rows("SELECT id FROM admin_audit_log")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Redis application cache", () => {
|
||||
it("writes to real Redis with expiry and reads it after memory invalidation", async () => {
|
||||
const key = `integration:${randomUUID()}:catalog`;
|
||||
let fetches = 0;
|
||||
const fetch = async () => ({ revision: ++fetches });
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(await appRedis?.get(key)).toBe('{"revision":1}');
|
||||
expect(await appRedis?.ttl(key)).toBeGreaterThan(0);
|
||||
cache.invalidateMemory(key);
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 1 });
|
||||
expect(fetches).toBe(1);
|
||||
await appRedis?.del(key);
|
||||
cache.invalidateMemory(key);
|
||||
expect(await cache.cached(key, 60_000, fetch)).toEqual({ revision: 2 });
|
||||
});
|
||||
|
||||
it("keeps two independently named cache entries isolated during invalidation", async () => {
|
||||
const first = `integration:${randomUUID()}:first`;
|
||||
const second = `integration:${randomUUID()}:second`;
|
||||
await cache.cached(first, 60_000, async () => "first");
|
||||
await cache.cached(second, 60_000, async () => "second");
|
||||
await appRedis?.del(first);
|
||||
cache.invalidateMemory(first);
|
||||
cache.invalidateMemory(second);
|
||||
expect(await cache.cached(first, 60_000, async () => "updated")).toBe(
|
||||
"updated",
|
||||
);
|
||||
expect(await cache.cached(second, 60_000, async () => "wrong")).toBe(
|
||||
"second",
|
||||
);
|
||||
expect(await appRedis?.get(second)).toBe('"second"');
|
||||
});
|
||||
});
|
||||
+3
-1
@@ -39,7 +39,8 @@
|
||||
"test:e2e": "playwright test",
|
||||
"test:ui": "playwright test --config playwright.ui.config.ts",
|
||||
"test:ui:update": "playwright test --config playwright.ui.config.ts --update-snapshots",
|
||||
"performance:report": "node scripts/performance-report.mjs"
|
||||
"performance:report": "node scripts/performance-report.mjs",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "1.8.0",
|
||||
@@ -100,6 +101,7 @@
|
||||
"pino-pretty": "13.1.3",
|
||||
"postcss": "8.5.28",
|
||||
"tailwindcss": "4.3.3",
|
||||
"testcontainers": "12.1.0",
|
||||
"tsx": "4.23.13",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.0",
|
||||
|
||||
Generated
+957
-2
File diff suppressed because it is too large.
Load diff
@@ -4,6 +4,9 @@ allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
msw: false
|
||||
cpu-features: false
|
||||
protobufjs: false
|
||||
ssh2: false
|
||||
|
||||
minimumReleaseAge: 60
|
||||
|
||||
|
||||
+7
-1
@@ -25,7 +25,13 @@ export default defineConfig({
|
||||
DATABASE_URL: "mysql://root:root@localhost:3306/test",
|
||||
},
|
||||
testTimeout: 10000,
|
||||
exclude: ["e2e/**", "node_modules/**", ".next/**", ".next.prev/**"],
|
||||
exclude: [
|
||||
"integration/**",
|
||||
"e2e/**",
|
||||
"node_modules/**",
|
||||
".next/**",
|
||||
".next.prev/**",
|
||||
],
|
||||
coverage: {
|
||||
enabled: true,
|
||||
provider: "v8",
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
"server-only": fileURLToPath(
|
||||
new URL("./src/test/server-only-stub.ts", import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["integration/**/*.test.ts"],
|
||||
fileParallelism: false,
|
||||
maxWorkers: 1,
|
||||
hookTimeout: 180_000,
|
||||
testTimeout: 60_000,
|
||||
coverage: { enabled: false },
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user