learning_ai_invt_trdg/backend/testSupabaseOrderPersistenceRegressions.ts

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();