Initial Commit
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const PKG_ROOT = join(__dirname, "..");
|
||||
const SRC = join(PKG_ROOT, "src");
|
||||
|
||||
async function walk(dir: string): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
for (const entry of await readdir(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const s = await stat(full);
|
||||
if (s.isDirectory()) {
|
||||
out.push(...(await walk(full)));
|
||||
} else {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("each adapter has its own subpath", { timeout: 5000 }, async () => {
|
||||
const pkg = JSON.parse(
|
||||
await readFile(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const exportKeys = Object.keys(pkg.exports);
|
||||
expect(exportKeys).toContain("./adapters/surrealql/surrealql");
|
||||
expect(exportKeys).toContain("./adapters/postgres/postgres");
|
||||
expect(exportKeys).toContain("./adapters/sqlite/sqlite");
|
||||
});
|
||||
|
||||
it("subpath exports cover every feature module", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const pkg = JSON.parse(
|
||||
await readFile(join(PKG_ROOT, "package.json"), "utf8")
|
||||
) as { exports: Record<string, unknown> };
|
||||
const expected = [
|
||||
".",
|
||||
"./runner/runner",
|
||||
"./adapters/surrealql/surrealql",
|
||||
"./adapters/postgres/postgres",
|
||||
"./adapters/sqlite/sqlite",
|
||||
"./otel/instruments",
|
||||
"./error-codes/error-codes",
|
||||
"./types/migration",
|
||||
];
|
||||
for (const key of expected) {
|
||||
expect(Object.keys(pkg.exports)).toContain(key);
|
||||
}
|
||||
});
|
||||
|
||||
it("every impl .ts has a sibling .md", { timeout: 5000 }, async () => {
|
||||
const files = await walk(SRC);
|
||||
const missing: string[] = [];
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".ts")) continue;
|
||||
if (file.endsWith(".test.ts")) continue;
|
||||
if (file.endsWith(".types.ts")) continue;
|
||||
if (file.endsWith("/index.ts")) continue;
|
||||
if (file.endsWith("/api-migrations.spec-conformance.test.ts")) {
|
||||
continue;
|
||||
}
|
||||
const md = file.replace(/\.ts$/, ".md");
|
||||
try {
|
||||
await stat(md);
|
||||
} catch {
|
||||
missing.push(md);
|
||||
}
|
||||
}
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
# Integration tests — deferred
|
||||
|
||||
The three dialect roundtrip tests (`sqlite`, `postgres`, `surrealql`) are
|
||||
deferred to a follow-up change. Justification:
|
||||
|
||||
- **sqlite-wasm** requires a Web Worker and the WASM binary; running it under
|
||||
vitest+Node in `forks` pool needs a non-trivial polyfill layer that does
|
||||
not yet ship in `@suaveplan/testing` and would inflate this change beyond
|
||||
scope. The `MemoryAdapter` in `src/runner/runner.test.ts` exercises the
|
||||
full apply/select/insert/delete roundtrip against a real in-memory store
|
||||
satisfying the same `MigrationAdapter` contract — every spec scenario is
|
||||
covered.
|
||||
- **postgres** and **surrealql** roundtrips require Docker testcontainers
|
||||
(`@suaveplan/testing-integration-postgres`, `-surrealdb`) which are
|
||||
available but pull large images on every CI run. They will land in
|
||||
`add-api-migrations-integration-tests` together with the sqlite-wasm worker
|
||||
setup so the env requirements stay coherent.
|
||||
|
||||
The shipped unit tests cover:
|
||||
|
||||
- DDL emission per dialect (`src/adapters/<dialect>/<dialect>.test.ts`).
|
||||
- Row normalisation roundtrip per dialect.
|
||||
- Parameterised insert/delete per dialect.
|
||||
- Full apply / drift-detect / rollback flow via the in-memory `MemoryAdapter`
|
||||
in `src/runner/runner.test.ts`.
|
||||
|
||||
These do not stub crypto, fs, or the adapter contract — every test
|
||||
exercises the real `node:crypto` `createHash` and `node:fs/promises`
|
||||
`readdir`/`readFile`/`stat` calls.
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Vitest globalSetup — registers the SurrealDB fixture in the globalSetup
|
||||
* process's module scope, then re-exports the testing-integration
|
||||
* `setup`/`teardown` contract.
|
||||
*
|
||||
* The same module pattern used by `@suaveplan/surrealdb` integration tests:
|
||||
* vitest's `globalSetup` runs in a separate process from the worker pool, so
|
||||
* the fixture must be registered in THIS file (which the globalSetup process
|
||||
* loads) for `createIntegrationSetup`'s registry to be populated when
|
||||
* `setup`/`teardown` run.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { createIntegrationSetup } from "@suaveplan/testing-integration";
|
||||
import { SurrealDBFixture } from "@suaveplan/testing-integration-surrealdb";
|
||||
|
||||
createIntegrationSetup({
|
||||
surrealdb: new SurrealDBFixture(),
|
||||
});
|
||||
|
||||
export {
|
||||
setup,
|
||||
teardown,
|
||||
} from "@suaveplan/testing-integration/setup/global-setup";
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared integration-setup module — exposes the typed `inject` helper and a
|
||||
* `surrealUnavailable` boolean for `describe.skipIf(...)` skip-when-down
|
||||
* behaviour. Fixture registration happens in the sibling `global-setup.ts`
|
||||
* file because vitest runs `globalSetup` in a separate process from the
|
||||
* worker pool.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { inject as vitestInject } from "@suaveplan/testing/runner";
|
||||
import type { SurrealDBConfig } from "@suaveplan/testing-integration-surrealdb";
|
||||
|
||||
interface IntegrationConfigs {
|
||||
surrealdb: SurrealDBConfig;
|
||||
}
|
||||
|
||||
export function inject<K extends keyof IntegrationConfigs>(
|
||||
key: K
|
||||
): IntegrationConfigs[K] {
|
||||
const all = (vitestInject as (k: string) => IntegrationConfigs)(
|
||||
"__integration_configs__"
|
||||
);
|
||||
return all[key];
|
||||
}
|
||||
|
||||
/**
|
||||
* `true` when the SurrealDB fixture failed to start (no Docker, env-bridge
|
||||
* incomplete) or when the test runs outside the integration config (no
|
||||
* globalSetup at all). Test files use this with `describe.skipIf(...)` so
|
||||
* the suite SKIPS rather than FAILS.
|
||||
*/
|
||||
export const surrealUnavailable: boolean = (() => {
|
||||
try {
|
||||
const failed = (vitestInject as (k: string) => string[] | undefined)(
|
||||
"__integration_failed__"
|
||||
);
|
||||
if (failed === undefined) return true;
|
||||
if (failed.includes("surrealdb")) return true;
|
||||
const configs = (
|
||||
vitestInject as (
|
||||
k: string
|
||||
) => Record<string, Record<string, unknown>> | undefined
|
||||
)("__integration_configs__");
|
||||
return configs === undefined || configs.surrealdb === undefined;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* SurrealQL adapter — live SurrealDB v3 ledger regression suite.
|
||||
*
|
||||
* Reproduces the silent-ledger-drop discovered while shipping
|
||||
* `@suaveplan/api-plugin-database`: with the pre-fix code, the runner's
|
||||
* `CREATE ${table}:$name SET ...` form parses cleanly under SurrealDB v3
|
||||
* but records nothing, so the ledger reports zero applied migrations and
|
||||
* every boot re-runs the same migration.
|
||||
*
|
||||
* These tests run against a real `surrealdb:v3.x` testcontainer started by
|
||||
* `tests/integration/global-setup.ts`. No mocks.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
afterEach,
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
} from "@suaveplan/testing/runner";
|
||||
import { Surreal } from "surrealdb";
|
||||
import {
|
||||
createSurrealqlAdapter,
|
||||
type SurrealqlClient,
|
||||
} from "../../src/adapters/surrealql/surrealql.js";
|
||||
import { createMigrationRunner } from "../../src/runner/runner.js";
|
||||
import { inject, surrealUnavailable } from "./integration-setup.js";
|
||||
|
||||
const TABLE = "_migrations";
|
||||
|
||||
/**
|
||||
* Thin wrapper that adapts the official `surrealdb` JS SDK to the structural
|
||||
* `SurrealqlClient` shape the migration adapter expects. The SDK's
|
||||
* `query<R extends unknown[]>(sql, bindings)` returns the raw statement-result
|
||||
* array; we flatten the single statement to the rows shape `selectApplied`
|
||||
* consumes.
|
||||
*/
|
||||
function wrapSurreal(client: Surreal): SurrealqlClient {
|
||||
return {
|
||||
async query<T>(
|
||||
sql: string,
|
||||
vars?: Record<string, unknown>
|
||||
): Promise<T[]> {
|
||||
const result = (await client.query(sql, vars)) as unknown[];
|
||||
// `query` returns an array of statement results. For multi-
|
||||
// statement SQL we flatten everything that's an array of rows;
|
||||
// single-row results are tolerated.
|
||||
const flat: T[] = [];
|
||||
for (const r of result) {
|
||||
if (Array.isArray(r)) flat.push(...(r as T[]));
|
||||
else if (r !== null && r !== undefined) flat.push(r as T);
|
||||
}
|
||||
return flat;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function connect(): Promise<Surreal> {
|
||||
const cfg = inject("surrealdb");
|
||||
const db = new Surreal();
|
||||
await db.connect(cfg.url);
|
||||
await db.signin({ username: cfg.username, password: cfg.password });
|
||||
await db.use({ namespace: cfg.namespace, database: cfg.database });
|
||||
return db;
|
||||
}
|
||||
|
||||
describe.skipIf(surrealUnavailable)(
|
||||
"createSurrealqlAdapter — live SurrealDB v3 ledger",
|
||||
() => {
|
||||
let dir: string;
|
||||
let db: Surreal;
|
||||
|
||||
beforeEach(async () => {
|
||||
dir = await mkdtemp(join(tmpdir(), "api-migrations-surreal-"));
|
||||
db = await connect();
|
||||
// Each test gets its own namespace/database for isolation. The
|
||||
// fixture's default ns/db are sufficient because we DROP the
|
||||
// ledger table in afterEach.
|
||||
}, 60000);
|
||||
|
||||
afterEach(async () => {
|
||||
try {
|
||||
await db.query(`REMOVE TABLE ${TABLE};`);
|
||||
} catch {
|
||||
// best-effort cleanup
|
||||
}
|
||||
await db.close();
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}, 60000);
|
||||
|
||||
it("ledger row is queryable after a migration body succeeds (regression: silent CREATE drop)", async () => {
|
||||
// Arrange: write a single migration body. The body creates a
|
||||
// table whose presence we can independently observe.
|
||||
const fileName = "001_initial.surql";
|
||||
const body = "DEFINE TABLE post_init SCHEMAFULL;";
|
||||
await writeFile(join(dir, fileName), body, "utf-8");
|
||||
|
||||
const client = wrapSurreal(db);
|
||||
const adapter = createSurrealqlAdapter(client);
|
||||
const runner = createMigrationRunner(adapter, {
|
||||
tableName: TABLE,
|
||||
fileExtension: ".surql",
|
||||
});
|
||||
|
||||
// Act
|
||||
const results = await runner.runFromDirectory(dir);
|
||||
|
||||
// Assert 1: runner reports success.
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]!.success).toBe(true);
|
||||
expect(results[0]!.name).toBe("001_initial");
|
||||
|
||||
// Assert 2: migration body actually applied.
|
||||
const bodyApplied = (await db.query("INFO FOR DB;")) as unknown[];
|
||||
expect(JSON.stringify(bodyApplied)).toContain("post_init");
|
||||
|
||||
// Assert 3 (the regression): the ledger row exists in the
|
||||
// SAME live connection. With the pre-fix `${table}:$name`
|
||||
// form this query returns []; the runner re-applies the
|
||||
// migration on every boot.
|
||||
const ledgerRows = (await db.query(
|
||||
`SELECT name, checksum, duration FROM ${TABLE};`
|
||||
)) as Array<
|
||||
Array<{ name: string; checksum: string; duration: number }>
|
||||
>;
|
||||
const rows = ledgerRows[0] ?? [];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.name).toBe("001_initial");
|
||||
expect(typeof rows[0]!.checksum).toBe("string");
|
||||
expect(rows[0]!.checksum).toMatch(/^[0-9a-f]{64}$/);
|
||||
}, 60000);
|
||||
|
||||
it("second run sees the ledger and skips already-applied migrations", async () => {
|
||||
const fileName = "001_idempotent.surql";
|
||||
const body = "DEFINE TABLE idempotent_t SCHEMAFULL;";
|
||||
await writeFile(join(dir, fileName), body, "utf-8");
|
||||
|
||||
const adapter = createSurrealqlAdapter(wrapSurreal(db));
|
||||
const runner = createMigrationRunner(adapter, {
|
||||
tableName: TABLE,
|
||||
fileExtension: ".surql",
|
||||
});
|
||||
|
||||
const first = await runner.runFromDirectory(dir);
|
||||
expect(first).toHaveLength(1);
|
||||
|
||||
// Second invocation MUST be a no-op. With the pre-fix bug
|
||||
// the ledger was empty so the runner would re-apply the
|
||||
// migration — which then fails because `DEFINE TABLE` is
|
||||
// idempotent here but in real schemas would crash on
|
||||
// duplicate constraints.
|
||||
const second = await runner.runFromDirectory(dir);
|
||||
expect(second).toEqual([]);
|
||||
|
||||
// Ledger still has exactly one row.
|
||||
const ledgerRows = (await db.query(
|
||||
`SELECT name FROM ${TABLE};`
|
||||
)) as Array<Array<{ name: string }>>;
|
||||
expect(ledgerRows[0] ?? []).toHaveLength(1);
|
||||
}, 60000);
|
||||
|
||||
it("rollback() removes the ledger row written by insertApplied (round-trip)", async () => {
|
||||
const fileName = "001_rollback.surql";
|
||||
const body = "DEFINE TABLE rollback_t SCHEMAFULL;";
|
||||
await writeFile(join(dir, fileName), body, "utf-8");
|
||||
|
||||
const adapter = createSurrealqlAdapter(wrapSurreal(db));
|
||||
const runner = createMigrationRunner(adapter, {
|
||||
tableName: TABLE,
|
||||
fileExtension: ".surql",
|
||||
});
|
||||
await runner.runFromDirectory(dir);
|
||||
|
||||
const result = await runner.rollback();
|
||||
expect(result?.success).toBe(true);
|
||||
expect(result?.name).toBe("001_rollback");
|
||||
|
||||
const ledgerRows = (await db.query(
|
||||
`SELECT name FROM ${TABLE};`
|
||||
)) as Array<Array<{ name: string }>>;
|
||||
expect(ledgerRows[0] ?? []).toHaveLength(0);
|
||||
}, 60000);
|
||||
}
|
||||
);
|
||||
32
packages/api-stack/api/api-migrations/tests/property.test.ts
Normal file
32
packages/api-stack/api/api-migrations/tests/property.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, it } from "@suaveplan/testing/runner";
|
||||
import fc from "fast-check";
|
||||
import { computeChecksum } from "../src/runner/runner.js";
|
||||
|
||||
describe("property — SHA-256 checksum stability", () => {
|
||||
it("identical content produces identical SHA-256", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string(), (content) => {
|
||||
const a = computeChecksum(content);
|
||||
const b = computeChecksum(content);
|
||||
return a === b && /^[0-9a-f]{64}$/.test(a);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("different content produces different SHA-256", {
|
||||
timeout: 30000,
|
||||
}, () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.string({ minLength: 1 }),
|
||||
fc.string({ minLength: 1 }),
|
||||
(a, b) => {
|
||||
if (a === b) return true;
|
||||
return computeChecksum(a) !== computeChecksum(b);
|
||||
}
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Test-environment setup for @suaveplan/api-migrations.
|
||||
*
|
||||
* Wires a real in-memory OpenTelemetry MeterProvider BEFORE any source
|
||||
* module is imported, so the meter resolved at module load is the real
|
||||
* provider's meter — not the noop fallback. Tests that inspect emitted
|
||||
* metrics (counter `migrations.applied_total`, histogram
|
||||
* `migrations.apply_duration_ms`) read them out of
|
||||
* `testInMemoryMetricExporter`.
|
||||
*
|
||||
* Node ≥18 exposes `crypto.subtle` globally, so no crypto polyfill is
|
||||
* required. Server-tier: `node:fs/promises` is available natively.
|
||||
*/
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
|
||||
export const testInMemoryMetricExporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter: testInMemoryMetricExporter,
|
||||
exportIntervalMillis: 60_000,
|
||||
});
|
||||
|
||||
export const testMeterProvider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(testMeterProvider);
|
||||
|
||||
export async function flushTestMetrics() {
|
||||
await testMeterProvider.forceFlush();
|
||||
const collected = testInMemoryMetricExporter.getMetrics();
|
||||
testInMemoryMetricExporter.reset();
|
||||
return collected;
|
||||
}
|
||||
Reference in New Issue
Block a user