189 lines
6.6 KiB
TypeScript
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>;
|