284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { supabaseService } from '../src/services/SupabaseService.js';
|
|
|
|
type Row = Record<string, any>;
|
|
type Filter =
|
|
| { op: 'eq'; field: string; value: any }
|
|
| { op: 'in'; field: string; value: any[] }
|
|
| { op: 'lt'; field: string; value: any };
|
|
|
|
class FakeSupabaseClient {
|
|
public orders: Row[];
|
|
private rowId = 0;
|
|
|
|
constructor(seedOrders: Row[] = []) {
|
|
this.orders = seedOrders.map((row) => ({ ...row }));
|
|
for (const row of this.orders) {
|
|
this.rowId = Math.max(this.rowId, Number(String(row.id || '').replace(/[^0-9]/g, '')) || 0);
|
|
}
|
|
}
|
|
|
|
public nextId(): string {
|
|
this.rowId += 1;
|
|
return `row-${this.rowId}`;
|
|
}
|
|
|
|
from(table: string) {
|
|
if (table !== 'orders') {
|
|
throw new Error(`Fake client only supports orders table (received: ${table})`);
|
|
}
|
|
return new FakeOrdersQuery(this);
|
|
}
|
|
}
|
|
|
|
class FakeOrdersQuery implements PromiseLike<{ data: any; error: any }> {
|
|
private mode: 'select' | 'update' = 'select';
|
|
private filters: Filter[] = [];
|
|
private sortField: string | null = null;
|
|
private sortAscending = true;
|
|
private limitCount: number | null = null;
|
|
private updatePayload: Row = {};
|
|
|
|
constructor(private readonly db: FakeSupabaseClient) { }
|
|
|
|
select(_columns: string) {
|
|
this.mode = 'select';
|
|
return this;
|
|
}
|
|
|
|
eq(field: string, value: any) {
|
|
this.filters.push({ op: 'eq', field, value });
|
|
return this;
|
|
}
|
|
|
|
in(field: string, value: any[]) {
|
|
this.filters.push({ op: 'in', field, value });
|
|
return this;
|
|
}
|
|
|
|
lt(field: string, value: any) {
|
|
this.filters.push({ op: 'lt', field, value });
|
|
return this;
|
|
}
|
|
|
|
order(field: string, options?: { ascending?: boolean }) {
|
|
this.sortField = field;
|
|
this.sortAscending = options?.ascending !== false;
|
|
return this;
|
|
}
|
|
|
|
limit(count: number) {
|
|
this.limitCount = count;
|
|
return this;
|
|
}
|
|
|
|
update(payload: Row) {
|
|
this.mode = 'update';
|
|
this.updatePayload = { ...payload };
|
|
return this;
|
|
}
|
|
|
|
insert(rows: Row[]) {
|
|
const inserted = rows.map((row) => {
|
|
const id = row.id || this.db.nextId();
|
|
const createdAt = row.created_at || new Date().toISOString();
|
|
return { ...row, id, created_at: createdAt };
|
|
});
|
|
this.db.orders.push(...inserted);
|
|
return Promise.resolve({ data: inserted, error: null });
|
|
}
|
|
|
|
private matchesFilters(row: Row): boolean {
|
|
return this.filters.every((filter) => {
|
|
const value = row[filter.field];
|
|
if (filter.op === 'eq') {
|
|
return filter.value === null ? value == null : value === filter.value;
|
|
}
|
|
if (filter.op === 'in') {
|
|
return filter.value.includes(value);
|
|
}
|
|
|
|
const leftRaw = value;
|
|
const rightRaw = filter.value;
|
|
const leftTs = Date.parse(String(leftRaw || ''));
|
|
const rightTs = Date.parse(String(rightRaw || ''));
|
|
if (Number.isFinite(leftTs) && Number.isFinite(rightTs)) {
|
|
return leftTs < rightTs;
|
|
}
|
|
return String(leftRaw || '') < String(rightRaw || '');
|
|
});
|
|
}
|
|
|
|
private executeSelect(): { data: any; error: any } {
|
|
let rows = this.db.orders.filter((row) => this.matchesFilters(row)).map((row) => ({ ...row }));
|
|
if (this.sortField) {
|
|
const field = this.sortField;
|
|
const direction = this.sortAscending ? 1 : -1;
|
|
rows = rows.sort((a, b) => {
|
|
const aValue = a[field];
|
|
const bValue = b[field];
|
|
const aTs = Date.parse(String(aValue || ''));
|
|
const bTs = Date.parse(String(bValue || ''));
|
|
if (Number.isFinite(aTs) && Number.isFinite(bTs) && aTs !== bTs) {
|
|
return direction * (aTs - bTs);
|
|
}
|
|
if (aValue === bValue) return 0;
|
|
return direction * (String(aValue || '').localeCompare(String(bValue || '')));
|
|
});
|
|
}
|
|
if (this.limitCount !== null) {
|
|
rows = rows.slice(0, this.limitCount);
|
|
}
|
|
return { data: rows, error: null };
|
|
}
|
|
|
|
private executeUpdate(): { data: any; error: any } {
|
|
const updatedRows: Row[] = [];
|
|
for (const row of this.db.orders) {
|
|
if (!this.matchesFilters(row)) continue;
|
|
Object.assign(row, this.updatePayload);
|
|
updatedRows.push({ ...row });
|
|
}
|
|
return { data: updatedRows, error: null };
|
|
}
|
|
|
|
then<TResult1 = { data: any; error: any }, TResult2 = never>(
|
|
onfulfilled?: ((value: { data: any; error: any }) => TResult1 | PromiseLike<TResult1>) | null,
|
|
onrejected?: ((reason: any) => TResult2 | PromiseLike<TResult2>) | null
|
|
): Promise<TResult1 | TResult2> {
|
|
try {
|
|
const result = this.mode === 'update' ? this.executeUpdate() : this.executeSelect();
|
|
if (!onfulfilled) {
|
|
return Promise.resolve(result as TResult1);
|
|
}
|
|
return Promise.resolve(onfulfilled(result));
|
|
} catch (error) {
|
|
if (onrejected) {
|
|
return Promise.resolve(onrejected(error));
|
|
}
|
|
return Promise.reject(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function run() {
|
|
const originalClient = (supabaseService as any).client;
|
|
const fakeClient = new FakeSupabaseClient();
|
|
(supabaseService as any).client = fakeClient as any;
|
|
|
|
try {
|
|
const now = Date.now();
|
|
await supabaseService.logOrder({
|
|
user_id: 'u-1',
|
|
profile_id: 'p-1',
|
|
order_id: 'ORD-1',
|
|
symbol: 'BTC/USD',
|
|
type: 'market',
|
|
side: 'BUY',
|
|
qty: 1,
|
|
price: 100,
|
|
status: 'pending_new',
|
|
timestamp: now,
|
|
trade_id: 'TRD-1',
|
|
action: 'ENTRY',
|
|
sub_tag: 'BL:PAPER:PTEST:TTEST:ENTRY'
|
|
});
|
|
assert.equal(fakeClient.orders.length, 1, 'Expected first order insert to create one row.');
|
|
|
|
await supabaseService.logOrder({
|
|
user_id: 'u-1',
|
|
profile_id: 'p-1',
|
|
order_id: 'ORD-1',
|
|
symbol: 'BTC/USD',
|
|
type: 'market',
|
|
side: 'BUY',
|
|
qty: 1,
|
|
price: 100,
|
|
status: 'pending_new',
|
|
timestamp: now + 1000,
|
|
trade_id: 'TRD-1',
|
|
action: 'ENTRY'
|
|
});
|
|
assert.equal(fakeClient.orders.length, 1, 'Duplicate order_id within same profile must be upserted, not inserted.');
|
|
|
|
await supabaseService.updateOrderStatus('ORD-1', 'filled', new Date(now + 2000), 101, 1.5);
|
|
await supabaseService.logOrder({
|
|
user_id: 'u-1',
|
|
profile_id: 'p-1',
|
|
order_id: 'ORD-1',
|
|
symbol: 'BTC/USD',
|
|
type: 'market',
|
|
side: 'BUY',
|
|
qty: 1,
|
|
price: 99,
|
|
status: 'pending_new',
|
|
timestamp: now + 3000,
|
|
trade_id: 'TRD-1',
|
|
action: 'ENTRY'
|
|
});
|
|
|
|
const persisted = fakeClient.orders.find((row) => row.order_id === 'ORD-1' && row.profile_id === 'p-1');
|
|
assert(persisted, 'Expected persisted order row to exist after updates.');
|
|
assert.equal(persisted.status, 'filled', 'Order status must not downgrade from terminal status on duplicate log.');
|
|
assert.equal(Number(persisted.qty), 1.5, 'Order qty must retain terminal filled quantity.');
|
|
assert.equal(Number(persisted.price), 101, 'Order price must retain terminal fill price.');
|
|
assert.equal(persisted.sub_tag, 'BL:PAPER:PTEST:TTEST:ENTRY', 'Order sub_tag must persist for traceability.');
|
|
|
|
await supabaseService.logOrder({
|
|
user_id: 'u-1',
|
|
profile_id: 'p-2',
|
|
order_id: 'ORD-SHARED',
|
|
symbol: 'ETH/USD',
|
|
type: 'market',
|
|
side: 'BUY',
|
|
qty: 1,
|
|
price: 2000,
|
|
status: 'pending_new',
|
|
timestamp: now,
|
|
trade_id: 'TRD-SHARED-1',
|
|
action: 'ENTRY'
|
|
});
|
|
await supabaseService.logOrder({
|
|
user_id: 'u-1',
|
|
profile_id: 'p-3',
|
|
order_id: 'ORD-SHARED',
|
|
symbol: 'ETH/USD',
|
|
type: 'market',
|
|
side: 'BUY',
|
|
qty: 1,
|
|
price: 2000,
|
|
status: 'pending_new',
|
|
timestamp: now + 10,
|
|
trade_id: 'TRD-SHARED-2',
|
|
action: 'ENTRY'
|
|
});
|
|
const sharedRows = fakeClient.orders.filter((row) => row.order_id === 'ORD-SHARED');
|
|
assert.equal(sharedRows.length, 2, 'Same exchange order id across different profiles must remain isolated.');
|
|
|
|
const staleCreatedAt = new Date(Date.now() - 10 * 60_000).toISOString();
|
|
fakeClient.orders.push(
|
|
{ id: fakeClient.nextId(), order_id: 'ORD-PN', status: 'pending_new', created_at: staleCreatedAt, symbol: 'BTC/USD' },
|
|
{ id: fakeClient.nextId(), order_id: 'ORD-P', status: 'pending', created_at: staleCreatedAt, symbol: 'BTC/USD' },
|
|
{ id: fakeClient.nextId(), order_id: 'ORD-A', status: 'accepted', created_at: staleCreatedAt, symbol: 'BTC/USD' },
|
|
{ id: fakeClient.nextId(), order_id: 'ORD-N', status: 'new', created_at: staleCreatedAt, symbol: 'BTC/USD' },
|
|
{ id: fakeClient.nextId(), order_id: 'ORD-F', status: 'filled', created_at: staleCreatedAt, symbol: 'BTC/USD' },
|
|
{ id: fakeClient.nextId(), order_id: 'ORD-YOUNG', status: 'pending_new', created_at: new Date().toISOString(), symbol: 'BTC/USD' }
|
|
);
|
|
|
|
const stale = await supabaseService.getStaleOrders(5);
|
|
const staleIds = new Set((stale || []).map((row: any) => String(row.order_id || row.id)));
|
|
assert(staleIds.has('ORD-PN'), 'Expected pending_new to be considered stale.');
|
|
assert(staleIds.has('ORD-P'), 'Expected pending to be considered stale.');
|
|
assert(staleIds.has('ORD-A'), 'Expected accepted to be considered stale.');
|
|
assert(staleIds.has('ORD-N'), 'Expected new to be considered stale.');
|
|
assert(!staleIds.has('ORD-F'), 'Filled orders must not be included in stale-pending query.');
|
|
assert(!staleIds.has('ORD-YOUNG'), 'Recent pending_new orders must not be included as stale.');
|
|
|
|
console.log('[supabase-order-persistence-regressions] OK: order upsert and stale status selection are stable');
|
|
} finally {
|
|
(supabaseService as any).client = originalClient;
|
|
}
|
|
}
|
|
|
|
await run();
|