test(ci): gate deployments on MariaDB and Redis integration checks
CI / check (push) Failing after 27s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-13 17:47:58 +02:00
1 parent 966a925614
commit 422be3ce2d
8 files changed
+1312 -4

No files matched your search

+308
View File
@@ -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"');
});
});