refactor: move profile reads onto backend api

This commit is contained in:
Saravana Achu Mac 2026-04-04 15:36:54 -07:00
parent 42420687f9
commit 535e0a88a9
10 changed files with 163 additions and 124 deletions

View File

@ -18,6 +18,7 @@ import {
deleteTradeProfileForUser, deleteTradeProfileForUser,
ensureDefaultTradeProfileForUser, ensureDefaultTradeProfileForUser,
getCurrentUserProfile, getCurrentUserProfile,
listAllTradeProfiles,
listTradeProfilesForUser, listTradeProfilesForUser,
saveTradeProfileForUser, saveTradeProfileForUser,
} from './profileRepository.js'; } from './profileRepository.js';
@ -1644,9 +1645,22 @@ export class ApiServer {
try { try {
const ensureDefault = String(req.query.ensureDefault || '').toLowerCase() === 'true'; const ensureDefault = String(req.query.ensureDefault || '').toLowerCase() === 'true';
const profiles = ensureDefault const scope = String(req.query.scope || 'user').toLowerCase();
? await ensureDefaultTradeProfileForUser(authUserId, supabaseService) const wantsAll = scope === 'all';
: await listTradeProfilesForUser(authUserId, supabaseService); const isAdmin = wantsAll ? await isTradingAdmin(authUserId, (req as AuthenticatedRequest).authRole) : false;
let profiles;
if (ensureDefault && !wantsAll) {
profiles = await ensureDefaultTradeProfileForUser(authUserId, supabaseService);
} else if (wantsAll) {
if (!isAdmin) {
res.status(403).json({ error: 'Forbidden: Admin role required' });
return;
}
profiles = await listAllTradeProfiles(supabaseService);
} else {
profiles = await listTradeProfilesForUser(authUserId, supabaseService);
}
res.json({ profiles }); res.json({ profiles });
} catch (error: any) { } catch (error: any) {
res.status(500).json({ error: `Failed to load profiles: ${error.message}` }); res.status(500).json({ error: `Failed to load profiles: ${error.message}` });

View File

@ -123,6 +123,29 @@ async function listProfilesFromCosmos(userId: string): Promise<TradeProfileRecor
.filter((profile): profile is TradeProfileRecord => Boolean(profile)); .filter((profile): profile is TradeProfileRecord => Boolean(profile));
} }
async function listAllProfilesFromCosmos(): Promise<TradeProfileRecord[]> {
if (!isCosmosConfigured()) {
return [];
}
const container = getContainer(PROFILE_CONTAINER);
const { resources } = await container.items.query<TradeProfileDocument>({
query: 'SELECT * FROM c WHERE c.productId = @productId AND c.type = @type ORDER BY c.createdAt DESC',
parameters: [
{ name: '@productId', value: config.PRODUCT_ID },
{ name: '@type', value: 'trade_profile' },
],
}).fetchAll();
return resources
.map((resource) => normalizeProfile({
...resource,
created_at: resource.createdAt,
updated_at: resource.updatedAt,
}))
.filter((profile): profile is TradeProfileRecord => Boolean(profile));
}
async function listProfilesFromSupabase(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> { async function listProfilesFromSupabase(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
const client = legacyService?.getClient?.(); const client = legacyService?.getClient?.();
if (!client) { if (!client) {
@ -149,6 +172,31 @@ async function listProfilesFromSupabase(userId: string, legacyService?: LegacySu
} }
} }
async function listAllProfilesFromSupabase(legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
const client = legacyService?.getClient?.();
if (!client) {
return [];
}
try {
const { data, error } = await client
.from('trade_profiles')
.select('id,user_id,name,allocated_capital,risk_per_trade_percent,symbols,is_active,strategy_config,created_at,updated_at')
.order('created_at', { ascending: false });
if (error || !Array.isArray(data)) {
return [];
}
return data
.map((row) => normalizeProfile(row as TradeProfileRecord))
.filter((profile): profile is TradeProfileRecord => Boolean(profile));
} catch (error) {
logger.warn(`[Profiles] Legacy global profile read failed: ${error instanceof Error ? error.message : 'unknown error'}`);
return [];
}
}
async function mirrorProfileToSupabase(profile: TradeProfileRecord, legacyService?: LegacySupabaseService): Promise<void> { async function mirrorProfileToSupabase(profile: TradeProfileRecord, legacyService?: LegacySupabaseService): Promise<void> {
const client = legacyService?.getClient?.(); const client = legacyService?.getClient?.();
if (!client) return; if (!client) return;
@ -202,6 +250,19 @@ export async function listTradeProfilesForUser(userId: string, legacyService?: L
return listProfilesFromSupabase(userId, legacyService); return listProfilesFromSupabase(userId, legacyService);
} }
export async function listAllTradeProfiles(legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
try {
const cosmosProfiles = await listAllProfilesFromCosmos();
if (cosmosProfiles.length > 0) {
return cosmosProfiles;
}
} catch (error) {
logger.warn(`[Profiles] Cosmos global profile read failed, falling back to legacy store: ${error instanceof Error ? error.message : 'unknown error'}`);
}
return listAllProfilesFromSupabase(legacyService);
}
export async function ensureDefaultTradeProfileForUser(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> { export async function ensureDefaultTradeProfileForUser(userId: string, legacyService?: LegacySupabaseService): Promise<TradeProfileRecord[]> {
const profiles = await listTradeProfilesForUser(userId, legacyService); const profiles = await listTradeProfilesForUser(userId, legacyService);
if (profiles.length > 0) { if (profiles.length > 0) {

View File

@ -63,8 +63,15 @@ export async function fetchCurrentUserProfile(): Promise<CurrentUserProfile> {
return response.profile; return response.profile;
} }
export async function fetchTradeProfiles(options?: { ensureDefault?: boolean }): Promise<TradeProfilePayload[]> { export async function fetchTradeProfiles(options?: { ensureDefault?: boolean; scope?: 'user' | 'all' }): Promise<TradeProfilePayload[]> {
const query = options?.ensureDefault ? '?ensureDefault=true' : ''; const params = new URLSearchParams();
if (options?.ensureDefault) {
params.set('ensureDefault', 'true');
}
if (options?.scope === 'all') {
params.set('scope', 'all');
}
const query = params.toString() ? `?${params.toString()}` : '';
const response = await apiRequest<{ profiles: TradeProfilePayload[] }>(`/api/profiles${query}`); const response = await apiRequest<{ profiles: TradeProfilePayload[] }>(`/api/profiles${query}`);
return Array.isArray(response.profiles) ? response.profiles : []; return Array.isArray(response.profiles) ? response.profiles : [];
} }

View File

@ -1,10 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { supabase } from '../lib/supabaseClient';
import { tableNameProfiles } from '../lib/const';
import { useAuth } from '../components/AuthContext'; import { useAuth } from '../components/AuthContext';
import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel'; import { BacktestRunnerPanel } from '../backtest/components/BacktestRunnerPanel';
import { BacktestComparePanel } from '../backtest/components/BacktestComparePanel'; import { BacktestComparePanel } from '../backtest/components/BacktestComparePanel';
import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate'; import { useBacktestFeatureGate } from '../backtest/useBacktestFeatureGate';
import { fetchTradeProfiles } from '../lib/profileApi';
interface BacktestTabProps { interface BacktestTabProps {
previewAsCustomer?: boolean; previewAsCustomer?: boolean;
@ -31,20 +30,16 @@ export const BacktestTab: React.FC<BacktestTabProps> = ({ previewAsCustomer = fa
return; return;
} }
setLoadingProfiles(true); setLoadingProfiles(true);
const { data, error } = await supabase try {
.from(tableNameProfiles) const data = await fetchTradeProfiles();
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) {
console.error('[BacktestTab] Failed to fetch profiles:', error);
setProfiles([]);
} else {
const nextProfiles = data || []; const nextProfiles = data || [];
setProfiles(nextProfiles); setProfiles(nextProfiles);
if (!nextProfiles.find((item: any) => item.id === selectedProfileId)) { if (!nextProfiles.find((item: any) => item.id === selectedProfileId)) {
setSelectedProfileId(nextProfiles[0]?.id || ''); setSelectedProfileId(nextProfiles[0]?.id || '');
} }
} catch (error) {
console.error('[BacktestTab] Failed to fetch profiles:', error);
setProfiles([]);
} }
setLoadingProfiles(false); setLoadingProfiles(false);
}; };

View File

@ -3,14 +3,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { HistoryTab } from './HistoryTab'; import { HistoryTab } from './HistoryTab';
import { tableNameProfiles, tableNameTransactions } from '../lib/const'; import { tableNameTransactions } from '../lib/const';
const { authState } = vi.hoisted(() => ({ const { authState, fetchTradeProfilesMock } = vi.hoisted(() => ({
authState: { authState: {
user: { id: 'u1' }, user: { id: 'u1' },
profile: { role: 'user' }, profile: { role: 'user' },
refreshProfile: vi.fn() refreshProfile: vi.fn()
} },
fetchTradeProfilesMock: vi.fn()
})); }));
vi.mock('../components/AuthContext', () => ({ vi.mock('../components/AuthContext', () => ({
@ -34,6 +35,10 @@ vi.mock('../lib/supabaseClient', () => ({
supabase: { from: vi.fn() } supabase: { from: vi.fn() }
})); }));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
}));
import { supabase } from '../lib/supabaseClient'; import { supabase } from '../lib/supabaseClient';
describe('HistoryTab Master Suite', () => { describe('HistoryTab Master Suite', () => {
@ -43,9 +48,9 @@ describe('HistoryTab Master Suite', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1', name: 'Alpha' }]);
(supabase.from as any).mockImplementation((table: string) => { (supabase.from as any).mockImplementation((table: string) => {
if (table === tableNameTransactions) return mockSupabaseChain(historyData); if (table === tableNameTransactions) return mockSupabaseChain(historyData);
if (table === tableNameProfiles) return mockSupabaseChain([{ id: 'p1', name: 'Alpha' }]);
return mockSupabaseChain([]); return mockSupabaseChain([]);
}); });
}); });
@ -85,7 +90,6 @@ describe('HistoryTab Master Suite', () => {
(supabase.from as any).mockImplementation((table: string) => { (supabase.from as any).mockImplementation((table: string) => {
if (table === tableNameTransactions) return mockSupabaseChain(manyRecords); if (table === tableNameTransactions) return mockSupabaseChain(manyRecords);
if (table === tableNameProfiles) return mockSupabaseChain([{ id: 'p1', name: 'Alpha' }]);
return mockSupabaseChain([]); return mockSupabaseChain([]);
}); });

View File

@ -1,12 +1,13 @@
import { useEffect, useState, useMemo } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { supabase } from '../lib/supabaseClient'; import { supabase } from '../lib/supabaseClient';
import { useAuth } from '../components/AuthContext'; import { useAuth } from '../components/AuthContext';
import { tableNameTransactions, tableNameProfiles, tableNameOrders } from '../lib/const'; import { tableNameTransactions, tableNameOrders } from '../lib/const';
import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger'; import { aggregateHistoryLedger, buildHistoryLedger } from '../lib/tradeHistoryLedger';
import { import {
type LifecycleOrderRow type LifecycleOrderRow
} from '../lib/orderLifecycleLedger'; } from '../lib/orderLifecycleLedger';
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle'; import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
import { fetchTradeProfiles } from '../lib/profileApi';
interface TradeRecord { interface TradeRecord {
@ -353,18 +354,16 @@ export const HistoryTab = ({ botState }: HistoryTabProps) => {
historyData = histDataLegacy as TradeRecord[] | null; historyData = histDataLegacy as TradeRecord[] | null;
} }
let profilesQuery = supabase let profData: Array<{ id: string; name: string; allocated_capital?: number }> = [];
.from(tableNameProfiles) try {
.select('id, name, allocated_capital') const profiles = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
.order('name', { ascending: true }); profData = profiles.map((item: any) => ({
id: String(item.id),
if (profile?.role !== 'admin') { name: String(item.name || item.id || 'Unnamed Profile'),
profilesQuery = profilesQuery.eq('user_id', user.id); allocated_capital: Number(item.allocated_capital || 0)
} }));
} catch (error: any) {
const { data: profData, error: profError } = await profilesQuery; console.error('[HistoryTab] Failed loading profiles:', error.message || error);
if (profError) {
console.error('[HistoryTab] Failed loading profiles:', profError.message);
} }
const orderTagColumnsV2 = 'order_id,trade_id,profile_id,sub_tag,timestamp,filled_at,created_at,symbol,side,action,qty,quantity,price,status,source'; const orderTagColumnsV2 = 'order_id,trade_id,profile_id,sub_tag,timestamp,filled_at,created_at,symbol,side,action,qty,quantity,price,status,source';

View File

@ -3,27 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react'; import { render, screen, waitFor } from '@testing-library/react';
import { OverviewTab, dedupeLivePositions } from './OverviewTab'; import { OverviewTab, dedupeLivePositions } from './OverviewTab';
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket'; import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
import { tableNameProfiles } from '../lib/const';
const { authMock, supabaseMock, createMockQuery, canonicalLifecycleHookState } = vi.hoisted(() => {
const createMockQuery = (data: any, error: any = null) => {
const query: any = {
select: vi.fn(), eq: vi.fn(), order: vi.fn(), limit: vi.fn(), then: vi.fn()
};
query.select.mockReturnValue(query);
query.eq.mockReturnValue(query);
query.order.mockReturnValue(query);
query.limit.mockReturnValue(query);
query.then.mockImplementation((cb: any) => Promise.resolve({ data, error }).then(cb));
return query;
};
const { authMock, fetchTradeProfilesMock, canonicalLifecycleHookState } = vi.hoisted(() => {
return { return {
createMockQuery,
authMock: { user: { id: 'u1' }, profile: { role: 'user' } }, authMock: { user: { id: 'u1' }, profile: { role: 'user' } },
supabaseMock: { fetchTradeProfilesMock: vi.fn(),
from: vi.fn()
},
canonicalLifecycleHookState: { canonicalLifecycleHookState: {
snapshot: null as any, snapshot: null as any,
loading: false, loading: false,
@ -39,8 +23,8 @@ vi.mock('../components/AuthContext', () => ({
}) })
})); }));
vi.mock('../lib/supabaseClient', () => ({ vi.mock('../lib/profileApi', () => ({
supabase: supabaseMock fetchTradeProfiles: fetchTradeProfilesMock
})); }));
vi.mock('../hooks/useCanonicalLifecycle', () => ({ vi.mock('../hooks/useCanonicalLifecycle', () => ({
@ -70,13 +54,9 @@ describe('OverviewTab coverage maximization', () => {
profileSignals: { 'p1': { signal: 'BUY', passed: true } } profileSignals: { 'p1': { signal: 'BUY', passed: true } }
} }
}; };
fetchTradeProfilesMock.mockResolvedValue([
supabaseMock.from.mockImplementation((table: string) => { { id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
if (table === tableNameProfiles) return createMockQuery([ ]);
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
]);
return createMockQuery([]);
});
}); });
it('covers all uptime and duration formatting paths', async () => { it('covers all uptime and duration formatting paths', async () => {
@ -99,7 +79,7 @@ describe('OverviewTab coverage maximization', () => {
}); });
it('covers fallback capital logic', async () => { it('covers fallback capital logic', async () => {
supabaseMock.from.mockImplementation(() => createMockQuery([])); fetchTradeProfilesMock.mockResolvedValue([]);
mockBotState.settings.totalCapital = 25000; mockBotState.settings.totalCapital = 25000;
render(<OverviewTab botState={mockBotState} />); render(<OverviewTab botState={mockBotState} />);
// The label in bot-status-bar is "Allocated:" // The label in bot-status-bar is "Allocated:"
@ -107,22 +87,16 @@ describe('OverviewTab coverage maximization', () => {
}); });
it('covers Signal Active branches', async () => { it('covers Signal Active branches', async () => {
supabaseMock.from.mockImplementation((table: string) => { fetchTradeProfilesMock.mockResolvedValueOnce([
if (table === tableNameProfiles) return createMockQuery([ { id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 0, strategy_config: { execution: { cooldownMinutes: 5 } } }
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 0, strategy_config: { execution: { cooldownMinutes: 5 } } } ]);
]);
return createMockQuery([]);
});
const { unmount } = render(<OverviewTab botState={mockBotState} />); const { unmount } = render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Signal active, no allocation/i)).toBeInTheDocument()); await waitFor(() => expect(screen.getByText(/Signal active, no allocation/i)).toBeInTheDocument());
unmount(); unmount();
supabaseMock.from.mockImplementation((table: string) => { fetchTradeProfilesMock.mockResolvedValueOnce([
if (table === tableNameProfiles) return createMockQuery([ { id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } }
{ id: 'p1', name: 'Alpha', is_active: true, allocated_capital: 5000, strategy_config: { execution: { cooldownMinutes: 5 } } } ]);
]);
return createMockQuery([]);
});
render(<OverviewTab botState={mockBotState} />); render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(screen.getByText(/Signal active, waiting entry/i)).toBeInTheDocument()); await waitFor(() => expect(screen.getByText(/Signal active, waiting entry/i)).toBeInTheDocument());
}); });
@ -225,12 +199,12 @@ describe('OverviewTab coverage maximization', () => {
it('covers Admin role data fetching', async () => { it('covers Admin role data fetching', async () => {
authMock.profile.role = 'admin'; authMock.profile.role = 'admin';
render(<OverviewTab botState={mockBotState} />); render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(supabaseMock.from).toHaveBeenCalled()); await waitFor(() => expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ scope: 'all' }));
}); });
it('covers error handling in fetchData', async () => { it('covers error handling in fetchData', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { }); const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
supabaseMock.from.mockImplementation(() => { throw new Error('Query Fail'); }); fetchTradeProfilesMock.mockRejectedValueOnce(new Error('Query Fail'));
render(<OverviewTab botState={mockBotState} />); render(<OverviewTab botState={mockBotState} />);
await waitFor(() => expect(consoleSpy).toHaveBeenCalled()); await waitFor(() => expect(consoleSpy).toHaveBeenCalled());
consoleSpy.mockRestore(); consoleSpy.mockRestore();

View File

@ -1,10 +1,9 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import type { BotState } from '../hooks/useWebSocket'; import type { BotState } from '../hooks/useWebSocket';
import { supabase } from '../lib/supabaseClient';
import { tableNameProfiles } from '../lib/const';
import { useAuth } from '../components/AuthContext'; import { useAuth } from '../components/AuthContext';
import { aggregateCanonicalLifecycleTrades } from '../lib/orderLifecycleLedger'; import { aggregateCanonicalLifecycleTrades } from '../lib/orderLifecycleLedger';
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle'; import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
import { fetchTradeProfiles } from '../lib/profileApi';
interface OverviewTabProps { interface OverviewTabProps {
botState: BotState; botState: BotState;
@ -133,19 +132,7 @@ export const OverviewTab = ({ botState, previewAsCustomer = false, connected = t
if (cancelled) return; if (cancelled) return;
try { try {
let profilesQuery = supabase const profilesData = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
.from(tableNameProfiles)
.select('id, name, allocated_capital, is_active, strategy_config');
if (profile?.role !== 'admin') {
profilesQuery = profilesQuery.eq('user_id', user.id);
}
const { data: profilesData, error: profilesError } = await profilesQuery;
if (profilesError) {
console.error('[OverviewTab] Failed loading profiles:', profilesError.message);
}
const activeProfileRows: ActiveProfileCapital[] = ((profilesData as any[]) || []) const activeProfileRows: ActiveProfileCapital[] = ((profilesData as any[]) || [])
.filter((p: any) => Boolean(p.is_active)) .filter((p: any) => Boolean(p.is_active))

View File

@ -4,7 +4,7 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { PositionsTab } from './PositionsTab'; import { PositionsTab } from './PositionsTab';
import type { BotState } from '../hooks/useWebSocket'; import type { BotState } from '../hooks/useWebSocket';
import { tableNameOrders, tableNameProfiles, tableNameStocks } from '../lib/const'; import { tableNameOrders, tableNameStocks } from '../lib/const';
const { const {
authState, authState,
@ -13,7 +13,7 @@ const {
entriesEqMock, entriesEqMock,
ordersEqMock, ordersEqMock,
historyEqMock, historyEqMock,
profilesEqMock fetchTradeProfilesMock
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
authState: { authState: {
user: { id: 'user-1' } as any, user: { id: 'user-1' } as any,
@ -24,7 +24,7 @@ const {
entriesEqMock: vi.fn(), entriesEqMock: vi.fn(),
ordersEqMock: vi.fn(), ordersEqMock: vi.fn(),
historyEqMock: vi.fn(), historyEqMock: vi.fn(),
profilesEqMock: vi.fn() fetchTradeProfilesMock: vi.fn()
})); }));
vi.mock('../components/AuthContext', () => ({ vi.mock('../components/AuthContext', () => ({
@ -40,12 +40,16 @@ vi.mock('../lib/supabaseClient', () => ({
} }
})); }));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
}));
interface QueryResult { interface QueryResult {
data: any; data: any;
error: any; error: any;
} }
type TableKey = 'entries' | 'orders' | 'trade_history' | 'trade_profiles'; type TableKey = 'entries' | 'orders' | 'trade_history';
const makeBuilder = (result: QueryResult, eqSpy: (field: string, value: any) => void) => { const makeBuilder = (result: QueryResult, eqSpy: (field: string, value: any) => void) => {
const builder: any = { const builder: any = {
@ -65,8 +69,7 @@ const configureQueries = (plan: Record<TableKey, QueryResult[]>) => {
const queues = { const queues = {
entries: [...plan.entries], entries: [...plan.entries],
orders: [...plan.orders], orders: [...plan.orders],
trade_history: [...plan.trade_history], trade_history: [...plan.trade_history]
trade_profiles: [...plan.trade_profiles]
}; };
fromMock.mockImplementation((table: string) => { fromMock.mockImplementation((table: string) => {
@ -79,9 +82,6 @@ const configureQueries = (plan: Record<TableKey, QueryResult[]>) => {
if (table === 'trade_history') { if (table === 'trade_history') {
return makeBuilder(queues.trade_history.shift() || { data: [], error: null }, historyEqMock); return makeBuilder(queues.trade_history.shift() || { data: [], error: null }, historyEqMock);
} }
if (table === tableNameProfiles) {
return makeBuilder(queues.trade_profiles.shift() || { data: [], error: null }, profilesEqMock);
}
return makeBuilder({ data: [], error: null }, vi.fn()); return makeBuilder({ data: [], error: null }, vi.fn());
}); });
}; };
@ -262,7 +262,11 @@ describe('PositionsTab DOM behavior', () => {
entriesEqMock.mockReset(); entriesEqMock.mockReset();
ordersEqMock.mockReset(); ordersEqMock.mockReset();
historyEqMock.mockReset(); historyEqMock.mockReset();
profilesEqMock.mockReset(); fetchTradeProfilesMock.mockReset();
fetchTradeProfilesMock.mockResolvedValue([
{ id: 'p1', name: 'High Risk Scalper' },
{ id: 'p2', name: 'Conservative Bag' }
]);
vi.stubGlobal('confirm', vi.fn(() => true)); vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal('alert', vi.fn()); vi.stubGlobal('alert', vi.fn());
@ -361,13 +365,6 @@ describe('PositionsTab DOM behavior', () => {
trade_history: [{ trade_history: [{
data: [{ trade_id: 'TRD-BOT-3', profile_id: 'p2' }, { trade_id: '', profile_id: 'p1' }], data: [{ trade_id: 'TRD-BOT-3', profile_id: 'p2' }, { trade_id: '', profile_id: 'p1' }],
error: null error: null
}],
trade_profiles: [{
data: [
{ id: 'p1', name: 'High Risk Scalper' },
{ id: 'p2', name: 'Conservative Bag' }
],
error: null
}] }]
}); });
@ -449,7 +446,6 @@ describe('PositionsTab DOM behavior', () => {
error: null error: null
}], }],
trade_history: [{ data: [], error: null }], trade_history: [{ data: [], error: null }],
trade_profiles: [{ data: [{ id: 'p1', name: 'High Risk Scalper' }], error: null }]
}); });
const user = userEvent.setup(); const user = userEvent.setup();
@ -507,9 +503,9 @@ describe('PositionsTab DOM behavior', () => {
{ data: null, error: { message: 'v2 fallback failed' } }, { data: null, error: { message: 'v2 fallback failed' } },
{ data: null, error: { message: 'legacy failed' } } { data: null, error: { message: 'legacy failed' } }
], ],
trade_history: [{ data: null, error: { message: 'history failed' } }], trade_history: [{ data: null, error: { message: 'history failed' } }]
trade_profiles: [{ data: null, error: { message: 'profiles failed' } }]
}); });
fetchTradeProfilesMock.mockRejectedValueOnce(new Error('profiles failed'));
render(<PositionsTab botState={{ render(<PositionsTab botState={{
symbols: {}, symbols: {},
@ -536,7 +532,7 @@ describe('PositionsTab DOM behavior', () => {
expect(entriesEqMock).toHaveBeenCalledWith('user_id', 'user-2'); expect(entriesEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(ordersEqMock).toHaveBeenCalledWith('user_id', 'user-2'); expect(ordersEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(historyEqMock).toHaveBeenCalledWith('user_id', 'user-2'); expect(historyEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(profilesEqMock).toHaveBeenCalledWith('user_id', 'user-2'); expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ scope: 'user' });
expect(warnSpy).toHaveBeenCalledWith( expect(warnSpy).toHaveBeenCalledWith(
'[PositionsTab] V3 order query failed, falling back to v2 columns:', '[PositionsTab] V3 order query failed, falling back to v2 columns:',

View File

@ -1,11 +1,12 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import type { BotState } from '../hooks/useWebSocket'; import type { BotState } from '../hooks/useWebSocket';
import { supabase } from '../lib/supabaseClient'; import { supabase } from '../lib/supabaseClient';
import { tradingRuntime } from '../lib/runtime'; import { tradingRuntime } from '../lib/runtime';
import { useAuth } from '../components/AuthContext'; import { useAuth } from '../components/AuthContext';
import { tableNameStocks, tableNameOrders, tableNameProfiles } from '../lib/const'; import { tableNameStocks, tableNameOrders } from '../lib/const';
import { Layers, ListFilter, Link2, GitBranch, AlertTriangle, Lock, RefreshCw, CheckCircle, XCircle } from 'lucide-react'; import { Layers, ListFilter, Link2, GitBranch, AlertTriangle, Lock, RefreshCw, CheckCircle, XCircle } from 'lucide-react';
import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle'; import { useCanonicalLifecycle } from '../hooks/useCanonicalLifecycle';
import { fetchTradeProfiles } from '../lib/profileApi';
interface PositionsTabProps { interface PositionsTabProps {
botState: BotState; botState: BotState;
@ -523,16 +524,17 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
console.warn('[PositionsTab] History trade lookup failed:', histError.message); console.warn('[PositionsTab] History trade lookup failed:', histError.message);
} }
let profilesQuery = supabase let profData: Array<{ id: string; name: string }> = [];
.from(tableNameProfiles) let profError: Error | null = null;
.select('id, name') try {
.order('name', { ascending: true }); const profiles = await fetchTradeProfiles({ scope: profile?.role === 'admin' ? 'all' : 'user' });
profData = profiles.map((item: any) => ({
if (profile?.role !== 'admin') { id: String(item.id),
profilesQuery = profilesQuery.eq('user_id', user.id); name: String(item.name || item.id || 'Unnamed Profile')
} }));
} catch (error) {
const { data: profData, error: profError } = await profilesQuery; profError = error as Error;
}
if (posError) { if (posError) {
console.error('[PositionsTab] Failed loading manual positions:', posError.message); console.error('[PositionsTab] Failed loading manual positions:', posError.message);