fix: align web verification with platform session contracts
This commit is contained in:
parent
5d3be081ee
commit
f73f855eb0
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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());
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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}`
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user