fix: align web verification with platform session contracts

This commit is contained in:
Saravana Achu Mac 2026-04-04 18:00:30 -07:00
parent 5d3be081ee
commit f73f855eb0
7 changed files with 286 additions and 431 deletions

View File

@ -226,7 +226,7 @@ Manual mobile release smoke is still required before broad rollout:
- Cosmos-only execution persistence is now in place for the main backend runtime paths, but dormant legacy code and one-off reference scripts still need cleanup
- web still carries some compatibility layers around auth/profile bootstrap
- root `pnpm verify` is currently blocked by a web Vitest localStorage harness issue that needs its own cleanup pass
- root `pnpm verify` is green again after aligning the web Vitest harness with platform-session storage and current API contracts
- mobile does not yet include push notification infrastructure
- feature-flag ownership and correlation-ID propagation are not fully standardized yet

View File

@ -507,7 +507,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
- [ ] Mitigation: migrate by module and purpose
- [ ] Mitigation: reject dead or duplicate bootstrap code
### Risk: auth model becomes split between Supabase-specific flows and platform-service flows
### Risk: auth model remains split between compatibility layers and the target platform-service session contract
- [x] Mitigation: preserve domain behavior while removing migration-only storage fallbacks
- [ ] Mitigation: define one authoritative session model early
@ -516,7 +516,7 @@ Validate that the new monorepo is safer and more coherent than the legacy setup
### Risk: repo-level verification stays red due to test-harness drift instead of product regressions
- [x] Mitigation: keep backend safety gates green while cutting over persistence
- [!] Mitigation: fix the current web Vitest `window.localStorage` harness issue before claiming a fully green root `pnpm verify`
- [x] Mitigation: fix the web Vitest `window.localStorage` harness issue and keep root `pnpm verify` green while API contracts evolve
### Risk: kill switch becomes semantically overloaded

View File

@ -3,43 +3,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TradeProfileManager } from './TradeProfileManager';
import { tableNameTransactions } from '../lib/const';
const {
authState,
fromMock,
historySelectMock,
fetchTradeProfilesMock,
fetchCurrentUserProfileMock,
createTradeProfileMock,
updateTradeProfileMock,
deleteTradeProfileMock,
setTradeProfileActiveMock
setTradeProfileActiveMock,
fetchTradeHistoryMock
} = vi.hoisted(() => ({
authState: {
user: { id: 'user-1', email: 'owner@demo.test' } as any,
profile: { role: 'admin' } as any
},
fromMock: vi.fn(),
historySelectMock: vi.fn(),
fetchTradeProfilesMock: vi.fn(),
fetchCurrentUserProfileMock: vi.fn(),
createTradeProfileMock: vi.fn(),
updateTradeProfileMock: vi.fn(),
deleteTradeProfileMock: vi.fn(),
setTradeProfileActiveMock: vi.fn()
setTradeProfileActiveMock: vi.fn(),
fetchTradeHistoryMock: vi.fn()
}));
vi.mock('./AuthContext', () => ({
useAuth: () => authState
}));
vi.mock('../lib/supabaseClient', () => ({
supabase: {
from: fromMock
}
}));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock,
fetchCurrentUserProfile: fetchCurrentUserProfileMock,
@ -49,19 +40,22 @@ vi.mock('../lib/profileApi', () => ({
setTradeProfileActive: setTradeProfileActiveMock
}));
vi.mock('../lib/tradeHistoryApi', () => ({
fetchTradeHistory: fetchTradeHistoryMock
}));
describe('TradeProfileManager DOM flow', () => {
beforeEach(() => {
authState.user = { id: 'user-1', email: 'owner@demo.test' };
authState.profile = { role: 'admin' };
fromMock.mockReset();
historySelectMock.mockReset();
fetchTradeProfilesMock.mockReset();
fetchCurrentUserProfileMock.mockReset();
createTradeProfileMock.mockReset();
updateTradeProfileMock.mockReset();
deleteTradeProfileMock.mockReset();
setTradeProfileActiveMock.mockReset();
fetchTradeHistoryMock.mockReset();
fetchTradeProfilesMock.mockResolvedValue([
{
@ -95,47 +89,29 @@ describe('TradeProfileManager DOM flow', () => {
user_id: 'user-1',
email: 'owner@demo.test'
});
historySelectMock.mockResolvedValue({
data: [
{
id: 'h-1',
timestamp: '2026-02-15T10:00:00.000Z',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entry_price: 100,
exit_price: 110,
pnl: 10,
pnl_percent: 10,
reason: 'TP',
profile_id: 'profile-1',
created_at: '2026-02-15T10:00:01.000Z',
trade_id: 'TRD-1',
source: 'MANUAL'
}
],
error: null
});
fetchTradeHistoryMock.mockResolvedValue([
{
id: 'h-1',
timestamp: '2026-02-15T10:00:00.000Z',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entry_price: 100,
exit_price: 110,
pnl: 10,
pnl_percent: 10,
reason: 'TP',
profile_id: 'profile-1',
created_at: '2026-02-15T10:00:01.000Z',
trade_id: 'TRD-1',
source: 'MANUAL'
}
]);
createTradeProfileMock.mockResolvedValue({ id: 'profile-2' });
updateTradeProfileMock.mockResolvedValue({ id: 'profile-1' });
deleteTradeProfileMock.mockResolvedValue(undefined);
setTradeProfileActiveMock.mockResolvedValue({ id: 'profile-1', is_active: false });
fromMock.mockImplementation((table: string) => {
if (table === tableNameTransactions) {
return {
select: historySelectMock
};
}
return {
select: vi.fn().mockResolvedValue({ data: [], error: null }),
update: vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) })),
insert: vi.fn().mockResolvedValue({ error: null }),
delete: vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) }))
};
});
});
it('renders profile cards, supports search filtering, and refreshes data', async () => {

View File

@ -40,11 +40,26 @@ function parseJson<T>(value: string | null): T | null {
}
}
export function getStoredPlatformSession(): PlatformSession | null {
function getStorage(): Storage | null {
if (typeof window === 'undefined') return null;
const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY);
const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY);
const user = parseJson<PlatformSessionUser>(window.localStorage.getItem(USER_KEY));
const storage = window.localStorage;
if (!storage) return null;
if (
typeof storage.getItem !== 'function'
|| typeof storage.setItem !== 'function'
|| typeof storage.removeItem !== 'function'
) {
return null;
}
return storage;
}
export function getStoredPlatformSession(): PlatformSession | null {
const storage = getStorage();
if (!storage) return null;
const accessToken = storage.getItem(ACCESS_TOKEN_KEY);
const refreshToken = storage.getItem(REFRESH_TOKEN_KEY);
const user = parseJson<PlatformSessionUser>(storage.getItem(USER_KEY));
if (!accessToken || !refreshToken || !user?.id) {
return null;
}
@ -56,17 +71,19 @@ export function getStoredPlatformSession(): PlatformSession | null {
}
function savePlatformSession(session: PlatformSession): void {
if (typeof window === 'undefined') return;
window.localStorage.setItem(ACCESS_TOKEN_KEY, session.access_token);
window.localStorage.setItem(REFRESH_TOKEN_KEY, session.refresh_token);
window.localStorage.setItem(USER_KEY, JSON.stringify(session.user));
const storage = getStorage();
if (!storage) return;
storage.setItem(ACCESS_TOKEN_KEY, session.access_token);
storage.setItem(REFRESH_TOKEN_KEY, session.refresh_token);
storage.setItem(USER_KEY, JSON.stringify(session.user));
}
export function clearPlatformSession(): void {
if (typeof window === 'undefined') return;
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
const storage = getStorage();
if (!storage) return;
storage.removeItem(ACCESS_TOKEN_KEY);
storage.removeItem(REFRESH_TOKEN_KEY);
storage.removeItem(USER_KEY);
}
export function emitPlatformAuthChange(event: string, session: PlatformSession | null): void {

View File

@ -3,43 +3,52 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { HistoryTab } from './HistoryTab';
import { tableNameTransactions } from '../lib/const';
const { authState, fetchTradeProfilesMock } = vi.hoisted(() => ({
const {
authState,
fetchTradeProfilesMock,
fetchTradeHistoryMock,
fetchPositionsBootstrapMock
} = vi.hoisted(() => ({
authState: {
user: { id: 'u1' },
profile: { role: 'user' },
refreshProfile: vi.fn()
refreshProfile: vi.fn(),
session: { access_token: 'session-token' }
},
fetchTradeProfilesMock: vi.fn()
fetchTradeProfilesMock: vi.fn(),
fetchTradeHistoryMock: vi.fn(),
fetchPositionsBootstrapMock: vi.fn()
}));
vi.mock('../components/AuthContext', () => ({
useAuth: () => authState
}));
const mockSupabaseChain = (data: any, error: any = null) => {
const chain: any = {
select: vi.fn(), eq: vi.fn(), order: vi.fn(), not: vi.fn(), limit: vi.fn(), then: vi.fn()
};
chain.select.mockReturnValue(chain);
chain.eq.mockReturnValue(chain);
chain.order.mockReturnValue(chain);
chain.not.mockReturnValue(chain);
chain.limit.mockReturnValue(chain);
chain.then.mockImplementation((cb: any) => Promise.resolve({ data, error }).then(cb));
return chain;
};
vi.mock('../lib/supabaseClient', () => ({
supabase: { from: vi.fn() }
}));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
}));
import { supabase } from '../lib/supabaseClient';
vi.mock('../lib/tradeHistoryApi', () => ({
fetchTradeHistory: fetchTradeHistoryMock
}));
vi.mock('../lib/positionsApi', () => ({
fetchPositionsBootstrap: fetchPositionsBootstrapMock
}));
vi.mock('../hooks/useCanonicalLifecycle', () => ({
useCanonicalLifecycle: () => ({
snapshot: {
lifecycleRows: [{ tradeId: 'TRD-1' }],
realizedTrades: [],
openPositions: [],
diagnostics: { truncated: false }
},
loading: false,
error: null
})
}));
describe('HistoryTab Master Suite', () => {
const historyData = [
@ -48,10 +57,29 @@ describe('HistoryTab Master Suite', () => {
beforeEach(() => {
vi.clearAllMocks();
fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1', name: 'Alpha' }]);
(supabase.from as any).mockImplementation((table: string) => {
if (table === tableNameTransactions) return mockSupabaseChain(historyData);
return mockSupabaseChain([]);
authState.user = { id: 'u1' };
authState.profile = { role: 'user' };
authState.session = { access_token: 'session-token' };
fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1', name: 'Alpha', allocated_capital: 1000 }]);
fetchTradeHistoryMock.mockResolvedValue(historyData);
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [],
orders: [{
id: 'o1',
order_id: 'o1',
profile_id: 'p1',
symbol: 'BTC',
side: 'BUY',
qty: 1,
price: 50000,
status: 'filled',
timestamp: '2024-01-01T09:00:00Z',
trade_id: 'TRD-1',
action: 'ENTRY',
source: 'BOT'
}],
historyTradeKeys: [{ trade_id: 'TRD-1', profile_id: 'p1' }],
profiles: [{ id: 'p1', name: 'Alpha' }]
});
});
@ -80,23 +108,38 @@ describe('HistoryTab Master Suite', () => {
pnl: 1000,
pnl_percent: 2,
created_at: `2024-01-01T12:${String(i % 60).padStart(2, '0')}:00`,
side: 'BUY',
source: 'BOT',
profile_id: 'p1',
size: 1,
reason: 'Trend'
reason: 'Trend',
trade_id: `TRD-${i}`
}));
(supabase.from as any).mockImplementation((table: string) => {
if (table === tableNameTransactions) return mockSupabaseChain(manyRecords);
return mockSupabaseChain([]);
fetchTradeHistoryMock.mockResolvedValue(manyRecords);
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [],
orders: manyRecords.map((row) => ({
id: row.id,
order_id: row.id,
profile_id: 'p1',
symbol: row.symbol,
side: row.side,
qty: row.size,
price: row.entry_price,
status: 'filled',
timestamp: row.created_at,
trade_id: row.trade_id,
action: 'ENTRY',
source: 'BOT'
})),
historyTradeKeys: manyRecords.map((row) => ({ trade_id: row.trade_id, profile_id: 'p1' })),
profiles: [{ id: 'p1', name: 'Alpha' }]
});
const { container } = render(<HistoryTab />);
await screen.findByText(/Page 1 \/ 2/i, {}, { timeout: 5000 });
const dateInputs = container.querySelectorAll('input[type="date"]');
if (dateInputs.length >= 2) {
fireEvent.change(dateInputs[0], { target: { value: '2024-01-01' } });
@ -111,17 +154,13 @@ describe('HistoryTab Master Suite', () => {
await user.click(prevBtn);
await screen.findByText(/Page 1 \/ 2/i);
await user.click(screen.getByText(/Clear/i));
});
it('covers empty fallback and source mapping', async () => {
(supabase.from as any).mockImplementation((table: string) => {
if (table === tableNameTransactions) return mockSupabaseChain([
{ ...historyData[0], source: 'MANUAL', profile_id: null }
]);
return mockSupabaseChain([]);
});
fetchTradeHistoryMock.mockResolvedValue([
{ ...historyData[0], source: 'MANUAL', profile_id: null }
]);
render(<HistoryTab />);
await waitFor(() => expect(screen.queryByText(/MANUAL/i)).toBeInTheDocument());
});

View File

@ -4,87 +4,64 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PositionsTab } from './PositionsTab';
import type { BotState } from '../hooks/useWebSocket';
import { tableNameOrders, tableNameStocks } from '../lib/const';
const {
authState,
fromMock,
getSessionMock,
entriesEqMock,
ordersEqMock,
historyEqMock,
fetchTradeProfilesMock
fetchPositionsBootstrapMock,
getPlatformAccessTokenMock,
canonicalLifecycleState
} = vi.hoisted(() => ({
authState: {
user: { id: 'user-1' } as any,
profile: { role: 'admin' } as any
profile: { role: 'admin' } as any,
session: { access_token: 'session-token' } as any
},
fromMock: vi.fn(),
getSessionMock: vi.fn(),
entriesEqMock: vi.fn(),
ordersEqMock: vi.fn(),
historyEqMock: vi.fn(),
fetchTradeProfilesMock: vi.fn()
fetchPositionsBootstrapMock: vi.fn(),
getPlatformAccessTokenMock: vi.fn(),
canonicalLifecycleState: {
snapshot: {
lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }],
realizedTrades: [],
openPositions: [{
id: 'canonical-open-1',
profileId: 'p1',
profileName: 'High Risk Scalper',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entryPrice: 100,
currentPrice: 125,
pnl: 25,
pnlPercent: 25,
stopLoss: 90,
takeProfit: 130,
tradeId: 'TRD-POS-1',
lastEventAt: 1
}],
diagnostics: { truncated: false }
} as any
}
}));
vi.mock('../components/AuthContext', () => ({
useAuth: () => authState
}));
vi.mock('../lib/supabaseClient', () => ({
supabase: {
from: fromMock,
auth: {
getSession: getSessionMock
}
}
vi.mock('../lib/positionsApi', () => ({
fetchPositionsBootstrap: fetchPositionsBootstrapMock
}));
vi.mock('../lib/profileApi', () => ({
fetchTradeProfiles: fetchTradeProfilesMock
vi.mock('../lib/authSession', () => ({
getPlatformAccessToken: getPlatformAccessTokenMock
}));
interface QueryResult {
data: any;
error: any;
}
type TableKey = 'entries' | 'orders' | 'trade_history';
const makeBuilder = (result: QueryResult, eqSpy: (field: string, value: any) => void) => {
const builder: any = {
select: vi.fn(() => builder),
order: vi.fn(() => builder),
limit: vi.fn(() => builder),
eq: vi.fn((field: string, value: any) => {
eqSpy(field, value);
return builder;
}),
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject)
};
return builder;
};
const configureQueries = (plan: Record<TableKey, QueryResult[]>) => {
const queues = {
entries: [...plan.entries],
orders: [...plan.orders],
trade_history: [...plan.trade_history]
};
fromMock.mockImplementation((table: string) => {
if (table === tableNameStocks) {
return makeBuilder(queues.entries.shift() || { data: [], error: null }, entriesEqMock);
}
if (table === tableNameOrders) {
return makeBuilder(queues.orders.shift() || { data: [], error: null }, ordersEqMock);
}
if (table === 'trade_history') {
return makeBuilder(queues.trade_history.shift() || { data: [], error: null }, historyEqMock);
}
return makeBuilder({ data: [], error: null }, vi.fn());
});
};
vi.mock('../hooks/useCanonicalLifecycle', () => ({
useCanonicalLifecycle: () => ({
snapshot: canonicalLifecycleState.snapshot,
loading: false,
error: null
})
}));
const buildBotState = (now: number): BotState => {
const sharedSymbolMeta = {
@ -99,7 +76,7 @@ const buildBotState = (now: number): BotState => {
indicators: {}
} as any;
const botOrders = Array.from({ length: 24 }).map((_, idx) => ({
const botOrders = Array.from({ length: 12 }).map((_, idx) => ({
id: `bot-order-${idx}`,
order_id: `bot-order-${idx}`,
profileId: idx % 2 === 0 ? 'p1' : 'p2',
@ -176,7 +153,7 @@ const buildBotState = (now: number): BotState => {
tradeId: 'TRD-POS-1'
},
{
id: 'missing-trade',
id: 'manual-gap',
symbol: 'ETH/USDT',
side: 'BUY',
size: 1,
@ -187,56 +164,8 @@ const buildBotState = (now: number): BotState => {
unrealizedPnl: 200,
unrealizedPnlPercent: 10,
marketValue: 2200,
profileId: 'p1',
profileName: 'High Risk Scalper'
},
{
id: 'missing-entry',
symbol: 'SOL/USDT',
side: 'BUY',
size: 1,
entryPrice: 100,
currentPrice: 101,
stopLoss: 90,
takeProfit: 120,
unrealizedPnl: 1,
unrealizedPnlPercent: 1,
marketValue: 101,
profileId: 'p1',
profileName: 'High Risk Scalper',
tradeId: 'TRD-NO-ENTRY'
},
{
id: 'profile-mismatch',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entryPrice: 100,
currentPrice: 110,
stopLoss: 90,
takeProfit: 130,
unrealizedPnl: 10,
unrealizedPnlPercent: 10,
marketValue: 110,
profileId: 'p1',
profileName: 'High Risk Scalper',
tradeId: 'TRD-PROFILE-MISMATCH'
},
{
id: 'symbol-mismatch',
symbol: 'XRP/USDT',
side: 'BUY',
size: 1,
entryPrice: 1,
currentPrice: 1.1,
stopLoss: 0.8,
takeProfit: 1.2,
unrealizedPnl: 0.1,
unrealizedPnlPercent: 10,
marketValue: 1.1,
profileId: 'p1',
profileName: 'High Risk Scalper',
tradeId: 'TRD-SYMBOL-MISMATCH'
profileId: 'p2',
profileName: 'Conservative Bag'
}
] as any,
orders: botOrders as any,
@ -257,17 +186,30 @@ describe('PositionsTab DOM behavior', () => {
beforeEach(() => {
authState.user = { id: 'user-1' };
authState.profile = { role: 'admin' };
fromMock.mockReset();
getSessionMock.mockReset();
entriesEqMock.mockReset();
ordersEqMock.mockReset();
historyEqMock.mockReset();
fetchTradeProfilesMock.mockReset();
fetchTradeProfilesMock.mockResolvedValue([
{ id: 'p1', name: 'High Risk Scalper' },
{ id: 'p2', name: 'Conservative Bag' }
]);
authState.session = { access_token: 'session-token' };
fetchPositionsBootstrapMock.mockReset();
getPlatformAccessTokenMock.mockReset();
canonicalLifecycleState.snapshot = {
lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }],
realizedTrades: [],
openPositions: [{
id: 'canonical-open-1',
profileId: 'p1',
profileName: 'High Risk Scalper',
symbol: 'BTC/USDT',
side: 'BUY',
size: 1,
entryPrice: 100,
currentPrice: 125,
pnl: 25,
pnlPercent: 25,
stopLoss: 90,
takeProfit: 130,
tradeId: 'TRD-POS-1',
lastEventAt: 1
}],
diagnostics: { truncated: false }
};
vi.stubGlobal('confirm', vi.fn(() => true));
vi.stubGlobal('alert', vi.fn());
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
@ -275,97 +217,44 @@ describe('PositionsTab DOM behavior', () => {
}));
});
it('renders positions/orders/lifecycle and supports profile/date/sort/pagination controls', async () => {
it('renders positions, lifecycle, and controls from the bootstrap api', async () => {
const now = Date.now();
const dbOrders = [
{
id: 'db-merge',
order_id: 'merge-1',
profile_id: 'p1',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1.2,
price: 140,
status: 'pending_new',
timestamp: new Date(now - 20_000).toISOString(),
created_at: new Date(now - 20_000).toISOString(),
trade_id: 'TRD-MERGE',
action: 'ENTRY',
source: 'BOT'
},
{
id: 'db-profile',
order_id: 'db-profile',
profile_id: 'p2',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 2000,
status: 'filled',
timestamp: now - 120_000,
created_at: new Date(now - 120_000).toISOString(),
trade_id: 'TRD-PROFILE-MISMATCH',
action: 'ENTRY',
source: 'BOT'
},
{
id: 'db-symbol',
order_id: 'db-symbol',
profile_id: 'p1',
symbol: 'DOGE/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 0.2,
status: 'filled',
timestamp: now - 110_000,
created_at: new Date(now - 110_000).toISOString(),
trade_id: 'TRD-SYMBOL-MISMATCH',
action: 'ENTRY',
source: 'BOT'
}
];
const entriesRows = [
{
stock_instance_id: 'manual-live',
symbol: 'BTC/USDT',
quantity: 1,
buy_price: 100,
drop_threshold_for_buy: 95,
gain_threshold_for_sell: 115,
active: true,
status: 'active'
},
{
stock_instance_id: 'manual-no-live',
symbol: 'UNI/USDT',
quantity: 2,
buy_price: 10,
drop_threshold_for_buy: 9,
gain_threshold_for_sell: 12,
active: true,
status: 'active'
},
{
stock_instance_id: 'manual-invalid',
symbol: 'LINK/USDT',
quantity: 0,
buy_price: 20,
active: true,
status: 'active'
}
];
configureQueries({
entries: [{ data: entriesRows, error: null }],
orders: [{ data: dbOrders, error: null }],
trade_history: [{
data: [{ trade_id: 'TRD-BOT-3', profile_id: 'p2' }, { trade_id: '', profile_id: 'p1' }],
error: null
}]
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [
{
stock_instance_id: 'manual-live',
symbol: 'BTC/USDT',
quantity: 1,
buy_price: 100,
drop_threshold_for_buy: 95,
gain_threshold_for_sell: 115,
active: true,
status: 'active'
}
],
orders: [
{
id: 'db-profile',
order_id: 'db-profile',
profile_id: 'p2',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 2000,
status: 'filled',
timestamp: now - 120_000,
created_at: new Date(now - 120_000).toISOString(),
trade_id: 'TRD-PROFILE-MISMATCH',
action: 'ENTRY',
source: 'BOT'
}
],
historyTradeKeys: [{ trade_id: 'TRD-BOT-3', profile_id: 'p2' }],
profiles: [
{ id: 'p1', name: 'High Risk Scalper' },
{ id: 'p2', name: 'Conservative Bag' }
]
});
const user = userEvent.setup();
@ -374,83 +263,54 @@ describe('PositionsTab DOM behavior', () => {
await waitFor(() => {
expect(screen.getByText('Order Activity')).toBeInTheDocument();
expect(screen.getByText('Lifecycle Trace')).toBeInTheDocument();
expect(screen.getByText(/Lifecycle Mismatch Diagnostics/)).toBeInTheDocument();
expect(screen.getByText('Trade cycle tracing active')).toBeInTheDocument();
});
expect(screen.getByText('Position has no trade ID. Lifecycle tracing is degraded.')).toBeInTheDocument();
expect(screen.getAllByText(/no matching ENTRY order/i).length).toBeGreaterThan(0);
expect(screen.getByText('Trade cycle tracing active')).toBeInTheDocument();
expect(screen.queryByText('No active positions for this selection.')).not.toBeInTheDocument();
expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'all', limit: 5000 });
expect(screen.getAllByText('Conservative Bag').length).toBeGreaterThan(0);
const initialNextButtons = screen.getAllByRole('button', { name: 'Next' });
const initialPrevButtons = screen.getAllByRole('button', { name: 'Prev' });
await user.click(initialNextButtons[0]);
await user.click(initialPrevButtons[0]);
await user.click(initialNextButtons[1]);
await user.click(initialPrevButtons[1]);
await user.click(screen.getByRole('button', { name: 'High Risk Scalper' }));
await user.click(screen.getByRole('button', { name: 'Global' }));
expect(screen.getByText(/Lifecycle Mismatch Diagnostics/)).toBeInTheDocument();
expect(screen.queryByText('No active positions for this selection.')).not.toBeInTheDocument();
const selects = container.querySelectorAll('select');
expect(selects.length).toBeGreaterThanOrEqual(2);
await user.selectOptions(selects[0] as HTMLSelectElement, 'p1');
await user.selectOptions(selects[1] as HTMLSelectElement, 'p1');
const dateInputs = container.querySelectorAll('input[type="date"]');
expect(dateInputs.length).toBeGreaterThanOrEqual(4);
await user.type(dateInputs[0] as HTMLInputElement, '2026-02-15');
await user.type(dateInputs[1] as HTMLInputElement, '2026-02-16');
await user.click(screen.getAllByRole('button', { name: 'Clear' })[0]);
expect((dateInputs[0] as HTMLInputElement).value).toBe('');
expect((dateInputs[1] as HTMLInputElement).value).toBe('');
const firstPassSortButtons = screen
.getAllByRole('button')
.filter((button) => /Newest|Oldest/.test(button.textContent || ''));
await user.click(firstPassSortButtons[0]);
expect(screen.getAllByRole('button', { name: 'Oldest' }).length).toBeGreaterThan(0);
await user.type(dateInputs[2] as HTMLInputElement, '2026-02-15');
await user.type(dateInputs[3] as HTMLInputElement, '2026-02-16');
await user.click(screen.getAllByRole('button', { name: 'Clear' })[1]);
expect((dateInputs[2] as HTMLInputElement).value).toBe('');
expect((dateInputs[3] as HTMLInputElement).value).toBe('');
const secondPassSortButtons = screen
.getAllByRole('button')
.filter((button) => /Newest|Oldest/.test(button.textContent || ''));
await user.click(secondPassSortButtons[secondPassSortButtons.length - 1]);
}, 30000);
const nextButtons = screen.getAllByRole('button', { name: 'Next' });
const prevButtons = screen.getAllByRole('button', { name: 'Prev' });
await user.click(nextButtons[0]);
await user.click(prevButtons[0]);
});
it('handles square-off workflow for cancel, auth failure, api failure, and success', async () => {
const now = Date.now();
configureQueries({
entries: [{ data: [], error: null }],
fetchPositionsBootstrapMock.mockResolvedValue({
entries: [],
orders: [{
data: [{
id: 'entry-order',
order_id: 'entry-order',
profile_id: 'p1',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 100,
status: 'filled',
timestamp: now - 1_000,
created_at: new Date(now - 1_000).toISOString(),
trade_id: 'TRD-POS-1',
action: 'ENTRY',
source: 'BOT'
}],
error: null
id: 'entry-order',
order_id: 'entry-order',
profile_id: 'p1',
symbol: 'BTC/USDT',
type: 'Market',
side: 'BUY',
qty: 1,
price: 100,
status: 'filled',
timestamp: now - 1_000,
created_at: new Date(now - 1_000).toISOString(),
trade_id: 'TRD-POS-1',
action: 'ENTRY',
source: 'BOT'
}],
trade_history: [{ data: [], error: null }],
historyTradeKeys: [],
profiles: [{ id: 'p1', name: 'High Risk Scalper' }]
});
const user = userEvent.setup();
render(<PositionsTab botState={buildBotState(now)} />);
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
await waitFor(() => {
expect(screen.getAllByRole('button', { name: 'Square Off' }).length).toBeGreaterThan(0);
});
@ -461,10 +321,10 @@ describe('PositionsTab DOM behavior', () => {
.mockReturnValueOnce(true)
.mockReturnValueOnce(true);
getSessionMock
.mockResolvedValueOnce({ data: { session: null } })
.mockResolvedValueOnce({ data: { session: { access_token: 'token-1' } } })
.mockResolvedValueOnce({ data: { session: { access_token: 'token-2' } } });
getPlatformAccessTokenMock
.mockRejectedValueOnce(new Error('Not authenticated'))
.mockResolvedValueOnce('token-1')
.mockResolvedValueOnce('token-2');
vi.mocked(fetch)
.mockResolvedValueOnce({ json: async () => ({ success: false, error: 'broker rejected' }) } as any)
@ -488,24 +348,14 @@ describe('PositionsTab DOM behavior', () => {
await waitFor(() => {
expect(alert).toHaveBeenCalledWith(expect.stringContaining('Successfully closed'));
});
}, 30000);
});
it('applies non-admin scope and triggers legacy fallback/error paths', async () => {
it('handles non-admin bootstrap failures with empty-state fallback', async () => {
authState.user = { id: 'user-2' };
authState.profile = { role: 'trader' };
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
canonicalLifecycleState.snapshot = null;
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
configureQueries({
entries: [{ data: null, error: { message: 'entries failed' } }],
orders: [
{ data: null, error: { message: 'v2 columns missing' } },
{ data: null, error: { message: 'v2 fallback failed' } },
{ data: null, error: { message: 'legacy failed' } }
],
trade_history: [{ data: null, error: { message: 'history failed' } }]
});
fetchTradeProfilesMock.mockRejectedValueOnce(new Error('profiles failed'));
fetchPositionsBootstrapMock.mockRejectedValueOnce(new Error('bootstrap failed'));
render(<PositionsTab botState={{
symbols: {},
@ -526,37 +376,10 @@ describe('PositionsTab DOM behavior', () => {
await waitFor(() => {
expect(screen.getByText('No recent orders for this cluster.')).toBeInTheDocument();
expect(screen.getByText('No trade lifecycle traces available for this selection.')).toBeInTheDocument();
expect(screen.getByText('No active positions for this selection.')).toBeInTheDocument();
});
expect(entriesEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(ordersEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(historyEqMock).toHaveBeenCalledWith('user_id', 'user-2');
expect(fetchTradeProfilesMock).toHaveBeenCalledWith({ scope: 'user' });
expect(warnSpy).toHaveBeenCalledWith(
'[PositionsTab] V3 order query failed, falling back to v2 columns:',
'v2 columns missing'
);
expect(warnSpy).toHaveBeenCalledWith(
'[PositionsTab] V2 order query failed, falling back to legacy columns:',
'v2 fallback failed'
);
expect(warnSpy).toHaveBeenCalledWith(
'[PositionsTab] History trade lookup failed:',
'history failed'
);
expect(errorSpy).toHaveBeenCalledWith(
'[PositionsTab] Legacy order query failed:',
'legacy failed'
);
expect(errorSpy).toHaveBeenCalledWith(
'[PositionsTab] Failed loading manual positions:',
'entries failed'
);
expect(errorSpy).toHaveBeenCalledWith(
'[PositionsTab] Failed loading profiles:',
'profiles failed'
);
}, 30000);
expect(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'user', limit: 5000 });
expect(errorSpy).toHaveBeenCalledWith('[PositionsTab] Failed loading positions bootstrap:', 'bootstrap failed');
});
});

View File

@ -1366,10 +1366,10 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
onClick={async () => {
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
try {
const accessToken = getPlatformAccessToken();
const accessToken = await getPlatformAccessToken();
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/close`, {
method: 'POST',
headers: {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${accessToken}`
},