Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,315 @@
/**
* In-memory `DatabaseAdapter` test double — NOT a mock.
*
* Satisfies the `@suaveplan/db` `DatabaseAdapter` interface with a real
* implementation backed by `Map<table, Map<id, row>>`. Used by unit
* tests for fast feedback. Integration tests exercise the same
* repository / service code against a real SurrealDB testcontainer.
*
* The SurQL subset supported:
*
* - `SELECT * FROM <table>` / with `WHERE <field> = $p0` / `LIMIT n START m` / `ORDER BY created_at DESC`
* - `SELECT count() AS count FROM <table> [WHERE ...] GROUP ALL`
* - `UPDATE <table>:<id> SET k = $pN, ..., updated_at = time::now()` returning the updated row
* - `DELETE <table> WHERE ... RETURN BEFORE`
*
* This is enough surface to cover every code path in
* `BankRepository`, `BankAccountRepository`, and the
* `BankingService` policy layer. Routes are tested by mounting them
* against this adapter via the real `BankingService`.
*/
import type {
DatabaseAdapter,
DbOperationOptions,
MigrationConfig,
MigrationResult,
} from "@suaveplan/db";
import type { DatabaseLike } from "@suaveplan/types";
type Row = Record<string, unknown> & { id: string };
interface SelectFilter {
field?: string;
paramIndex?: number;
extraField?: string;
extraParamIndex?: number;
extraOp?: "=" | "!=";
limit?: number;
offset?: number;
orderByCreatedDesc?: boolean;
containsParamIndex?: number;
countOnly?: boolean;
}
const NOW = () => new Date().toISOString();
export class FakeDatabaseAdapter implements DatabaseAdapter {
public readonly tables = new Map<string, Map<string, Row>>();
public readonly callLog: string[] = [];
private seq = 0;
public failNextQuery = false;
public get isConnected(): boolean {
return true;
}
public async connect(): Promise<void> {}
public async disconnect(): Promise<void> {}
public async close(): Promise<void> {}
public async migrate(
_config?: MigrationConfig,
_opts?: DbOperationOptions
): Promise<MigrationResult> {
return { applied: 0, migrations: [] };
}
public async transaction<T>(
fn: (tx: DatabaseLike) => Promise<T>,
_opts?: DbOperationOptions
): Promise<T> {
return fn(this as unknown as DatabaseLike);
}
public async execute(
sql: string,
_params?: readonly unknown[],
_opts?: DbOperationOptions
): Promise<{ rowsAffected: number }> {
this.callLog.push(`execute:${sql.trim().split(/\s+/, 2).join(" ")}`);
return { rowsAffected: 0 };
}
public async query<T = Record<string, unknown>>(
sql: string,
params?: readonly unknown[],
_opts?: DbOperationOptions
): Promise<T[]> {
this.callLog.push(`query:${sql.trim().slice(0, 80)}`);
if (this.failNextQuery) {
this.failNextQuery = false;
throw new Error("Forced query failure");
}
return this.parseAndRun<T>(sql, params ?? []);
}
public async create<T extends Record<string, unknown>>(
table: string,
data: T,
_opts?: DbOperationOptions
): Promise<T & { id: string }> {
this.callLog.push(`create:${table}`);
const map = this.ensureTable(table);
const id = `${table}:${String(++this.seq)}`;
const row: Row = {
...data,
id,
created_at: NOW(),
updated_at: NOW(),
};
map.set(id, row);
return row as T & { id: string };
}
public async select<T extends Record<string, unknown>>(
table: string,
id: string,
_opts?: DbOperationOptions
): Promise<T | null> {
this.callLog.push(`select:${table}:${id}`);
const map = this.tables.get(table);
if (map === undefined) return null;
const fullId = id.includes(":") ? id : `${table}:${id}`;
const row = map.get(fullId);
return row === undefined ? null : (row as unknown as T);
}
public async update<T extends Record<string, unknown>>(
table: string,
id: string,
data: Partial<T>,
_opts?: DbOperationOptions
): Promise<T | null> {
const fullId = id.includes(":") ? id : `${table}:${id}`;
const map = this.tables.get(table);
if (map === undefined) return null;
const existing = map.get(fullId);
if (existing === undefined) return null;
const updated = { ...existing, ...data, updated_at: NOW() };
map.set(fullId, updated as Row);
return updated as unknown as T;
}
public async delete(
table: string,
id: string,
_opts?: DbOperationOptions
): Promise<void> {
const fullId = id.includes(":") ? id : `${table}:${id}`;
const map = this.tables.get(table);
if (map !== undefined) map.delete(fullId);
}
public async selectAll<T extends Record<string, unknown>>(
table: string,
_opts?: DbOperationOptions
): Promise<T[]> {
const map = this.tables.get(table);
if (map === undefined) return [];
return Array.from(map.values()) as unknown as T[];
}
private ensureTable(table: string): Map<string, Row> {
let map = this.tables.get(table);
if (map === undefined) {
map = new Map();
this.tables.set(table, map);
}
return map;
}
private parseAndRun<T>(sql: string, params: readonly unknown[]): T[] {
const trimmed = sql.replaceAll(/\s+/g, " ").trim();
// UPDATE table:id SET ... — returns updated row
const upd = trimmed.match(
/^UPDATE\s+([a-z_]+):([^\s]+)\s+SET\s+(.+)$/i
);
if (upd !== null) {
const table = upd[1];
const id = `${table}:${upd[2]}`;
const setClause = upd[3];
const map = this.tables.get(table);
if (map === undefined) return [];
const row = map.get(id);
if (row === undefined) return [];
const updated: Row = { ...row };
for (const part of setClause.split(",")) {
const m = part
.trim()
.match(
/^([a-z_]+)\s*=\s*(?:\$p(\d+)|([a-z_]+)\s*([+-])\s*\$p(\d+)|time::now\(\))/i
);
if (m === null) continue;
const field = m[1];
if (m[2] !== undefined) {
updated[field] = params[Number(m[2])];
} else if (m[3] !== undefined) {
const cur = Number(updated[m[3]] ?? 0);
const delta = Number(params[Number(m[5])]);
updated[field] = m[4] === "+" ? cur + delta : cur - delta;
} else {
updated[field] = NOW();
}
}
updated.updated_at = NOW();
map.set(id, updated);
return [updated as unknown as T];
}
// DELETE table WHERE ... RETURN BEFORE
const del = trimmed.match(
/^DELETE\s+([a-z_]+)\s+WHERE\s+(.+?)\s+RETURN\s+BEFORE$/i
);
if (del !== null) {
const table = del[1];
const map = this.tables.get(table);
if (map === undefined) return [];
const removed: Row[] = [];
const filter = this.parseWhereExpression(del[2], params);
for (const [id, row] of Array.from(map.entries())) {
if (filter(row)) {
removed.push(row);
map.delete(id);
}
}
return removed as unknown as T[];
}
// SELECT count() AS count FROM table [WHERE ...] GROUP ALL
const cnt = trimmed.match(
/^SELECT\s+count\(\)\s+AS\s+count\s+FROM\s+([a-z_]+)(?:\s+WHERE\s+(.+?))?\s+GROUP\s+ALL$/i
);
if (cnt !== null) {
const table = cnt[1];
const map = this.tables.get(table);
if (map === undefined) return [{ count: 0 } as unknown as T];
const where = cnt[2];
if (where === undefined) {
return [{ count: map.size } as unknown as T];
}
const filter = this.parseWhereExpression(where, params);
let n = 0;
for (const row of map.values()) if (filter(row)) n++;
return [{ count: n } as unknown as T];
}
// SELECT * FROM table [WHERE ...] [ORDER BY created_at DESC] [LIMIT n START m]
const sel = trimmed.match(
/^SELECT\s+(?:id|\*)\s+FROM\s+([a-z_]+)(?:\s+WHERE\s+(.+?))?(?:\s+ORDER\s+BY\s+created_at\s+DESC)?(?:\s+LIMIT\s+(\d+)\s+START\s+(\d+))?(?:\s+LIMIT\s+(\d+))?$/i
);
if (sel !== null) {
const table = sel[1];
const where = sel[2];
const limit = sel[3] ?? sel[5];
const start = sel[4];
const map = this.tables.get(table);
if (map === undefined) return [];
let rows = Array.from(map.values());
if (where !== undefined) {
const filter = this.parseWhereExpression(where, params);
rows = rows.filter(filter);
}
if (/ORDER\s+BY\s+created_at\s+DESC/i.test(trimmed)) {
rows = rows.sort((a, b) =>
String(b.created_at).localeCompare(String(a.created_at))
);
}
if (start !== undefined) {
rows = rows.slice(Number(start));
}
if (limit !== undefined) {
rows = rows.slice(0, Number(limit));
}
return rows as unknown as T[];
}
return [];
}
private parseWhereExpression(
expr: string,
params: readonly unknown[]
): (row: Row) => boolean {
const clauses = expr.split(/\s+AND\s+/i).map((c) => c.trim());
const predicates: ((row: Row) => boolean)[] = [];
for (const c of clauses) {
const eq = c.match(/^([a-z_]+)\s*=\s*\$p(\d+)$/i);
if (eq !== null) {
const f = eq[1];
const v = params[Number(eq[2])];
predicates.push((r) => r[f] === v);
continue;
}
const neq = c.match(/^([a-z_]+)\s*!=\s*\$p(\d+)$/i);
if (neq !== null) {
const f = neq[1];
const v = params[Number(neq[2])];
predicates.push((r) => r[f] !== v);
continue;
}
const cont = c.match(
/^string::lowercase\(\s*([a-z_]+)\s*\)\s+CONTAINS\s+string::lowercase\(\s*\$p(\d+)\s*\)$/i
);
if (cont !== null) {
const f = cont[1];
const v = String(params[Number(cont[2])] ?? "").toLowerCase();
predicates.push((r) =>
String(r[f] ?? "")
.toLowerCase()
.includes(v)
);
}
}
return (row) => predicates.every((p) => p(row));
}
}

View File

@@ -0,0 +1,34 @@
/**
* Test setup for @suaveplan/api-plugin-banking.
*
* Installs a real in-memory OpenTelemetry MeterProvider BEFORE any
* source module imports so the meter resolved on first call binds to
* it. Tests that need fresh metric state per case call
* `resetBankingInstruments()` and `flushTestMetrics()` themselves.
*/
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;
}