Files
kooltab-backoffice/apps/api/src/testing/fake-prisma.ts
T
brammie15 0ca9f4d16a
CI / build-and-test (push) Canceled after 0s
feat: make server own tabs
2026-10-20 16:18:36 +02:00

189 lines
6.6 KiB
TypeScript

/**
* An in-memory stand-in for the Prisma client, for tests that follow a venue through several syncs. It supports the
* queries the tab, stock and device services use: equality, `in`, `notIn`, `not`, `gt`, `gte`, `lte` and compound
* unique keys, `orderBy` on one or more fields, `take` and `select`. Transactions run directly on the same data.
*/
import { randomUUID } from 'crypto';
type Row = Record<string, any>;
const COMPOUND_KEYS: Record<string, string[]> = {
venueId_id: ['venueId', 'id'],
venueId_productId: ['venueId', 'productId'],
};
function matches(row: Row, where: Row = {}): boolean {
return Object.entries(where).every(([field, condition]) => {
if (field in COMPOUND_KEYS) return COMPOUND_KEYS[field].every((key) => row[key] === condition[key]);
const value = row[field];
if (condition === null || typeof condition !== 'object' || condition instanceof Date) {
return equal(value, condition);
}
return Object.entries(condition).every(([op, operand]: [string, any]) => {
switch (op) {
case 'in': return operand.some((o: unknown) => equal(value, o));
case 'notIn': return !operand.some((o: unknown) => equal(value, o));
case 'not': return !equal(value, operand);
case 'gt': return value !== null && value > operand;
case 'gte': return value !== null && value >= operand;
case 'lte': return value !== null && value <= operand;
default: throw new Error(`fake-prisma: unsupported operator ${op}`);
}
});
});
}
function equal(a: unknown, b: unknown) {
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
return (a ?? null) === (b ?? null);
}
/** A deep copy, so tests can't change stored rows by accident. structuredClone's Dates fail instanceof under Jest. */
function clone<T>(value: T): T {
if (value instanceof Date) return new Date(value) as T;
if (Array.isArray(value)) return value.map(clone) as T;
if (value && typeof value === 'object') {
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clone(v)])) as T;
}
return value;
}
function pick(row: Row, select?: Row): Row {
const copy = clone(row);
if (!select) return copy;
return Object.fromEntries(Object.keys(select).filter((key) => select[key]).map((key) => [key, copy[key]]));
}
function sort(rows: Row[], orderBy?: Row | Row[]) {
const orders = orderBy ? (Array.isArray(orderBy) ? orderBy : [orderBy]) : [];
return [...rows].sort((a, b) => {
for (const order of orders) {
const [field, direction] = Object.entries(order)[0];
if (a[field] < b[field]) return direction === 'asc' ? -1 : 1;
if (a[field] > b[field]) return direction === 'asc' ? 1 : -1;
}
return 0;
});
}
class FakeTable {
rows: Row[] = [];
private sequence = 0;
constructor(private defaults: () => Row = () => ({}), private sequenceField?: string) {}
private insert(data: Row) {
// Like @default(uuid()); harmless on tables keyed otherwise
const row: Row = { id: randomUUID(), ...this.defaults(), ...clone(data) };
if (this.sequenceField) row[this.sequenceField] = ++this.sequence;
this.rows.push(row);
return row;
}
async findMany(args: Row = {}) {
const found = sort(this.rows.filter((row) => matches(row, args.where)), args.orderBy);
return found.slice(0, args.take ?? found.length).map((row) => pick(row, args.select));
}
async findUnique(args: Row) {
const row = this.rows.find((r) => matches(r, args.where));
return row ? pick(row, args.select) : null;
}
async findFirst(args: Row = {}) {
return (await this.findMany({ ...args, take: 1 }))[0] ?? null;
}
async create(args: Row) {
return pick(this.insert(args.data), args.select);
}
async createMany(args: Row) {
let count = 0;
for (const data of args.data) {
if (args.skipDuplicates && this.rows.some((row) => row.id !== undefined && row.id === data.id)) continue;
this.insert(data);
count++;
}
return { count };
}
async update(args: Row) {
const row = this.rows.find((r) => matches(r, args.where));
if (!row) throw new Error('fake-prisma: record to update not found');
Object.assign(row, clone(args.data));
return pick(row, args.select);
}
async updateMany(args: Row) {
const found = this.rows.filter((row) => matches(row, args.where));
for (const row of found) Object.assign(row, clone(args.data));
return { count: found.length };
}
async upsert(args: Row) {
const row = this.rows.find((r) => matches(r, args.where));
if (row) {
Object.assign(row, clone(args.update));
return pick(row);
}
return pick(this.insert(args.create));
}
async deleteMany(args: Row = {}) {
const before = this.rows.length;
this.rows = this.rows.filter((row) => !matches(row, args.where));
return { count: before - this.rows.length };
}
async aggregate(args: Row) {
const found = this.rows.filter((row) => matches(row, args.where));
const max = Object.fromEntries(Object.keys(args._max ?? {}).map((field) => [
field,
found.length === 0 ? null : Math.max(...found.map((row) => row[field])),
]));
return { _max: max };
}
}
export function createFakePrisma() {
const now = () => new Date();
const prisma = {
venue: new FakeTable(() => ({ tabsSeededAt: null })),
device: new FakeTable(),
tab: new FakeTable(() => ({ updatedAt: now() })),
product: new FakeTable(() => ({ stock: null, countedAt: null, lowStockThreshold: null })),
tabEvent: new FakeTable(),
settlement: new FakeTable(() => ({ createdAt: now() }), 'sequence'),
stockAdjustment: new FakeTable(() => ({ status: 'pending', stockAfter: null, result: null, processedAt: null, createdAt: now() })),
plannedTab: new FakeTable(() => ({ deliveredAt: null, email: null, position: 0 })),
remoteCommand: new FakeTable(),
auditLog: new FakeTable(),
tabReminder: new FakeTable(),
/** Only the venue lock of VenueLedger.open. */
async $queryRaw(strings: TemplateStringsArray, venueId: string) {
const venue = prisma.venue.rows.find((row) => row.id === venueId);
return venue ? [{ tabsSeededAt: venue.tabsSeededAt }] : [];
},
async $transaction(fn: unknown) {
if (typeof fn === 'function') return fn(prisma);
return Promise.all(fn as Promise<unknown>[]);
},
};
// Tab rows bump their update time like Prisma's @updatedAt
const upsertTab = prisma.tab.upsert.bind(prisma.tab);
prisma.tab.upsert = (args: Row) => upsertTab({
...args,
create: { ...args.create, updatedAt: now() },
update: { ...args.update, updatedAt: now() },
});
return prisma;
}
export type FakePrisma = ReturnType<typeof createFakePrisma>;