/** * 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; const COMPOUND_KEYS: Record = { 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(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[]); }, }; // 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;