422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
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 { sql } from "drizzle-orm";
|
|
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",
|
|
"0031_operations_outbox.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 operations: typeof import("@/features/operations/server");
|
|
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");
|
|
operations = await import("@/features/operations/server");
|
|
});
|
|
|
|
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("DELETE FROM cms_outbox");
|
|
await connection.query("DELETE FROM cms_operations");
|
|
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(
|
|
"4/4 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"');
|
|
});
|
|
});
|
|
|
|
describe("operation idempotency and transactional outbox", () => {
|
|
it("replays concurrent identical requests after applying their mutation and effect only once", async () => {
|
|
const request = {
|
|
actorId: 7,
|
|
kind: "integration.test",
|
|
key: randomUUID(),
|
|
input: { increment: 3 },
|
|
};
|
|
let executions = 0;
|
|
const work = async (
|
|
tx: import("@/features/operations/server").OperationTransaction,
|
|
id: string,
|
|
) => {
|
|
executions++;
|
|
await tx.execute(
|
|
sql`UPDATE catalog_items SET cost_credits=cost_credits+3 WHERE id=1`,
|
|
);
|
|
await operations.enqueueEffect(tx, id, "catalog.refresh");
|
|
return { operationId: id, changed: 1 };
|
|
};
|
|
const result = await Promise.all([
|
|
operations.runOperation(request, work),
|
|
operations.runOperation(request, work),
|
|
]);
|
|
expect(result[0]).toEqual(result[1]);
|
|
expect(executions).toBe(1);
|
|
expect(
|
|
await rows("SELECT cost_credits FROM catalog_items WHERE id=1"),
|
|
).toEqual([{ cost_credits: 13 }]);
|
|
expect(await rows("SELECT id FROM cms_operations")).toHaveLength(1);
|
|
expect(await rows("SELECT topic,status FROM cms_outbox")).toEqual([
|
|
{ topic: "catalog.refresh", status: "pending" },
|
|
]);
|
|
await expect(
|
|
operations.runOperation({ ...request, input: { increment: 9 } }, work),
|
|
).rejects.toThrow();
|
|
expect(executions).toBe(1);
|
|
expect(
|
|
await rows("SELECT cost_credits FROM catalog_items WHERE id=1"),
|
|
).toEqual([{ cost_credits: 13 }]);
|
|
});
|
|
|
|
it("rolls back the request, mutation and queued effect together, allowing the same request to retry", async () => {
|
|
const request = {
|
|
actorId: 7,
|
|
kind: "integration.test",
|
|
key: randomUUID(),
|
|
input: { increment: 3 },
|
|
};
|
|
await expect(
|
|
operations.runOperation(request, async (tx, id) => {
|
|
await tx.execute(
|
|
sql`UPDATE catalog_items SET cost_credits=99 WHERE id=1`,
|
|
);
|
|
await operations.enqueueEffect(tx, id, "catalog.refresh");
|
|
throw Error("failure after effect enqueue");
|
|
}),
|
|
).rejects.toThrow("failure after effect enqueue");
|
|
expect(
|
|
await rows("SELECT cost_credits FROM catalog_items WHERE id=1"),
|
|
).toEqual([{ cost_credits: 10 }]);
|
|
expect(await rows("SELECT id FROM cms_operations")).toHaveLength(0);
|
|
expect(await rows("SELECT id FROM cms_outbox")).toHaveLength(0);
|
|
await expect(
|
|
operations.runOperation(request, async (tx, id) => {
|
|
await tx.execute(
|
|
sql`UPDATE catalog_items SET cost_credits=13 WHERE id=1`,
|
|
);
|
|
await operations.enqueueEffect(tx, id, "catalog.refresh");
|
|
return { changed: 1 };
|
|
}),
|
|
).resolves.toEqual({ changed: 1 });
|
|
expect(
|
|
await rows("SELECT cost_credits FROM catalog_items WHERE id=1"),
|
|
).toEqual([{ cost_credits: 13 }]);
|
|
expect(await rows("SELECT id FROM cms_outbox")).toHaveLength(1);
|
|
});
|
|
|
|
it("leases a queued effect to only one consumer and ignores a stale completion token", async () => {
|
|
await operations.runOperation(
|
|
{ actorId: 7, kind: "integration.test", key: randomUUID(), input: {} },
|
|
async (tx, id) => {
|
|
await operations.enqueueEffect(tx, id, "catalog.refresh");
|
|
return { queued: true };
|
|
},
|
|
);
|
|
const claims = await Promise.all([
|
|
operations.effectRepository.claim(),
|
|
operations.effectRepository.claim(),
|
|
]);
|
|
expect(claims.filter(Boolean)).toHaveLength(1);
|
|
const claim = claims.find((value) => value !== null);
|
|
if (!claim) throw Error("Expected one effect lease");
|
|
await operations.effectRepository.complete({
|
|
...claim,
|
|
token: randomUUID(),
|
|
});
|
|
expect(await rows("SELECT status FROM cms_outbox")).toEqual([
|
|
{ status: "running" },
|
|
]);
|
|
await operations.effectRepository.complete(claim);
|
|
expect(await rows("SELECT status FROM cms_outbox")).toEqual([
|
|
{ status: "done" },
|
|
]);
|
|
});
|
|
});
|