diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index b06a8b3..3cbbcb0 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -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 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 22697af..4c1aa1a 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/web/src/components/TradeProfileManager.dom.test.tsx b/web/src/components/TradeProfileManager.dom.test.tsx index 862dd38..471a850 100644 --- a/web/src/components/TradeProfileManager.dom.test.tsx +++ b/web/src/components/TradeProfileManager.dom.test.tsx @@ -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 () => { diff --git a/web/src/lib/authSession.ts b/web/src/lib/authSession.ts index 6c29e9c..5002ce9 100644 --- a/web/src/lib/authSession.ts +++ b/web/src/lib/authSession.ts @@ -40,11 +40,26 @@ function parseJson(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(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(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 { diff --git a/web/src/tabs/HistoryTab.dom.test.tsx b/web/src/tabs/HistoryTab.dom.test.tsx index d9b4d77..d3b111e 100644 --- a/web/src/tabs/HistoryTab.dom.test.tsx +++ b/web/src/tabs/HistoryTab.dom.test.tsx @@ -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(); 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(); await waitFor(() => expect(screen.queryByText(/MANUAL/i)).toBeInTheDocument()); }); diff --git a/web/src/tabs/PositionsTab.dom.test.tsx b/web/src/tabs/PositionsTab.dom.test.tsx index 0f45cad..3ccd220 100644 --- a/web/src/tabs/PositionsTab.dom.test.tsx +++ b/web/src/tabs/PositionsTab.dom.test.tsx @@ -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) => { - 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(); + 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( { 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'); + }); }); diff --git a/web/src/tabs/PositionsTab.tsx b/web/src/tabs/PositionsTab.tsx index 6e82eca..bcf7a03 100644 --- a/web/src/tabs/PositionsTab.tsx +++ b/web/src/tabs/PositionsTab.tsx @@ -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}` },