Initial Commit
This commit is contained in:
315
packages/api/api-plugin-banking/tests/fake-adapter.ts
Normal file
315
packages/api/api-plugin-banking/tests/fake-adapter.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user