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
|
- 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
|
- 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
|
- mobile does not yet include push notification infrastructure
|
||||||
- feature-flag ownership and correlation-ID propagation are not fully standardized yet
|
- 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: migrate by module and purpose
|
||||||
- [ ] Mitigation: reject dead or duplicate bootstrap code
|
- [ ] 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
|
- [x] Mitigation: preserve domain behavior while removing migration-only storage fallbacks
|
||||||
- [ ] Mitigation: define one authoritative session model early
|
- [ ] 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
|
### 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
|
- [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
|
### 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 { render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { TradeProfileManager } from './TradeProfileManager';
|
import { TradeProfileManager } from './TradeProfileManager';
|
||||||
import { tableNameTransactions } from '../lib/const';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
authState,
|
authState,
|
||||||
fromMock,
|
|
||||||
historySelectMock,
|
|
||||||
fetchTradeProfilesMock,
|
fetchTradeProfilesMock,
|
||||||
fetchCurrentUserProfileMock,
|
fetchCurrentUserProfileMock,
|
||||||
createTradeProfileMock,
|
createTradeProfileMock,
|
||||||
updateTradeProfileMock,
|
updateTradeProfileMock,
|
||||||
deleteTradeProfileMock,
|
deleteTradeProfileMock,
|
||||||
setTradeProfileActiveMock
|
setTradeProfileActiveMock,
|
||||||
|
fetchTradeHistoryMock
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
authState: {
|
authState: {
|
||||||
user: { id: 'user-1', email: 'owner@demo.test' } as any,
|
user: { id: 'user-1', email: 'owner@demo.test' } as any,
|
||||||
profile: { role: 'admin' } as any
|
profile: { role: 'admin' } as any
|
||||||
},
|
},
|
||||||
fromMock: vi.fn(),
|
|
||||||
historySelectMock: vi.fn(),
|
|
||||||
fetchTradeProfilesMock: vi.fn(),
|
fetchTradeProfilesMock: vi.fn(),
|
||||||
fetchCurrentUserProfileMock: vi.fn(),
|
fetchCurrentUserProfileMock: vi.fn(),
|
||||||
createTradeProfileMock: vi.fn(),
|
createTradeProfileMock: vi.fn(),
|
||||||
updateTradeProfileMock: vi.fn(),
|
updateTradeProfileMock: vi.fn(),
|
||||||
deleteTradeProfileMock: vi.fn(),
|
deleteTradeProfileMock: vi.fn(),
|
||||||
setTradeProfileActiveMock: vi.fn()
|
setTradeProfileActiveMock: vi.fn(),
|
||||||
|
fetchTradeHistoryMock: vi.fn()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./AuthContext', () => ({
|
vi.mock('./AuthContext', () => ({
|
||||||
useAuth: () => authState
|
useAuth: () => authState
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/supabaseClient', () => ({
|
|
||||||
supabase: {
|
|
||||||
from: fromMock
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../lib/profileApi', () => ({
|
vi.mock('../lib/profileApi', () => ({
|
||||||
fetchTradeProfiles: fetchTradeProfilesMock,
|
fetchTradeProfiles: fetchTradeProfilesMock,
|
||||||
fetchCurrentUserProfile: fetchCurrentUserProfileMock,
|
fetchCurrentUserProfile: fetchCurrentUserProfileMock,
|
||||||
@ -49,19 +40,22 @@ vi.mock('../lib/profileApi', () => ({
|
|||||||
setTradeProfileActive: setTradeProfileActiveMock
|
setTradeProfileActive: setTradeProfileActiveMock
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../lib/tradeHistoryApi', () => ({
|
||||||
|
fetchTradeHistory: fetchTradeHistoryMock
|
||||||
|
}));
|
||||||
|
|
||||||
describe('TradeProfileManager DOM flow', () => {
|
describe('TradeProfileManager DOM flow', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authState.user = { id: 'user-1', email: 'owner@demo.test' };
|
authState.user = { id: 'user-1', email: 'owner@demo.test' };
|
||||||
authState.profile = { role: 'admin' };
|
authState.profile = { role: 'admin' };
|
||||||
|
|
||||||
fromMock.mockReset();
|
|
||||||
historySelectMock.mockReset();
|
|
||||||
fetchTradeProfilesMock.mockReset();
|
fetchTradeProfilesMock.mockReset();
|
||||||
fetchCurrentUserProfileMock.mockReset();
|
fetchCurrentUserProfileMock.mockReset();
|
||||||
createTradeProfileMock.mockReset();
|
createTradeProfileMock.mockReset();
|
||||||
updateTradeProfileMock.mockReset();
|
updateTradeProfileMock.mockReset();
|
||||||
deleteTradeProfileMock.mockReset();
|
deleteTradeProfileMock.mockReset();
|
||||||
setTradeProfileActiveMock.mockReset();
|
setTradeProfileActiveMock.mockReset();
|
||||||
|
fetchTradeHistoryMock.mockReset();
|
||||||
|
|
||||||
fetchTradeProfilesMock.mockResolvedValue([
|
fetchTradeProfilesMock.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@ -95,47 +89,29 @@ describe('TradeProfileManager DOM flow', () => {
|
|||||||
user_id: 'user-1',
|
user_id: 'user-1',
|
||||||
email: 'owner@demo.test'
|
email: 'owner@demo.test'
|
||||||
});
|
});
|
||||||
|
fetchTradeHistoryMock.mockResolvedValue([
|
||||||
historySelectMock.mockResolvedValue({
|
{
|
||||||
data: [
|
id: 'h-1',
|
||||||
{
|
timestamp: '2026-02-15T10:00:00.000Z',
|
||||||
id: 'h-1',
|
symbol: 'BTC/USDT',
|
||||||
timestamp: '2026-02-15T10:00:00.000Z',
|
side: 'BUY',
|
||||||
symbol: 'BTC/USDT',
|
size: 1,
|
||||||
side: 'BUY',
|
entry_price: 100,
|
||||||
size: 1,
|
exit_price: 110,
|
||||||
entry_price: 100,
|
pnl: 10,
|
||||||
exit_price: 110,
|
pnl_percent: 10,
|
||||||
pnl: 10,
|
reason: 'TP',
|
||||||
pnl_percent: 10,
|
profile_id: 'profile-1',
|
||||||
reason: 'TP',
|
created_at: '2026-02-15T10:00:01.000Z',
|
||||||
profile_id: 'profile-1',
|
trade_id: 'TRD-1',
|
||||||
created_at: '2026-02-15T10:00:01.000Z',
|
source: 'MANUAL'
|
||||||
trade_id: 'TRD-1',
|
}
|
||||||
source: 'MANUAL'
|
]);
|
||||||
}
|
|
||||||
],
|
|
||||||
error: null
|
|
||||||
});
|
|
||||||
|
|
||||||
createTradeProfileMock.mockResolvedValue({ id: 'profile-2' });
|
createTradeProfileMock.mockResolvedValue({ id: 'profile-2' });
|
||||||
updateTradeProfileMock.mockResolvedValue({ id: 'profile-1' });
|
updateTradeProfileMock.mockResolvedValue({ id: 'profile-1' });
|
||||||
deleteTradeProfileMock.mockResolvedValue(undefined);
|
deleteTradeProfileMock.mockResolvedValue(undefined);
|
||||||
setTradeProfileActiveMock.mockResolvedValue({ id: 'profile-1', is_active: false });
|
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 () => {
|
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;
|
if (typeof window === 'undefined') return null;
|
||||||
const accessToken = window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
const storage = window.localStorage;
|
||||||
const refreshToken = window.localStorage.getItem(REFRESH_TOKEN_KEY);
|
if (!storage) return null;
|
||||||
const user = parseJson<PlatformSessionUser>(window.localStorage.getItem(USER_KEY));
|
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) {
|
if (!accessToken || !refreshToken || !user?.id) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -56,17 +71,19 @@ export function getStoredPlatformSession(): PlatformSession | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function savePlatformSession(session: PlatformSession): void {
|
function savePlatformSession(session: PlatformSession): void {
|
||||||
if (typeof window === 'undefined') return;
|
const storage = getStorage();
|
||||||
window.localStorage.setItem(ACCESS_TOKEN_KEY, session.access_token);
|
if (!storage) return;
|
||||||
window.localStorage.setItem(REFRESH_TOKEN_KEY, session.refresh_token);
|
storage.setItem(ACCESS_TOKEN_KEY, session.access_token);
|
||||||
window.localStorage.setItem(USER_KEY, JSON.stringify(session.user));
|
storage.setItem(REFRESH_TOKEN_KEY, session.refresh_token);
|
||||||
|
storage.setItem(USER_KEY, JSON.stringify(session.user));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearPlatformSession(): void {
|
export function clearPlatformSession(): void {
|
||||||
if (typeof window === 'undefined') return;
|
const storage = getStorage();
|
||||||
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
if (!storage) return;
|
||||||
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
|
storage.removeItem(ACCESS_TOKEN_KEY);
|
||||||
window.localStorage.removeItem(USER_KEY);
|
storage.removeItem(REFRESH_TOKEN_KEY);
|
||||||
|
storage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function emitPlatformAuthChange(event: string, session: PlatformSession | null): void {
|
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 { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { HistoryTab } from './HistoryTab';
|
import { HistoryTab } from './HistoryTab';
|
||||||
import { tableNameTransactions } from '../lib/const';
|
|
||||||
|
|
||||||
const { authState, fetchTradeProfilesMock } = vi.hoisted(() => ({
|
const {
|
||||||
|
authState,
|
||||||
|
fetchTradeProfilesMock,
|
||||||
|
fetchTradeHistoryMock,
|
||||||
|
fetchPositionsBootstrapMock
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
authState: {
|
authState: {
|
||||||
user: { id: 'u1' },
|
user: { id: 'u1' },
|
||||||
profile: { role: 'user' },
|
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', () => ({
|
vi.mock('../components/AuthContext', () => ({
|
||||||
useAuth: () => authState
|
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', () => ({
|
vi.mock('../lib/profileApi', () => ({
|
||||||
fetchTradeProfiles: fetchTradeProfilesMock
|
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', () => {
|
describe('HistoryTab Master Suite', () => {
|
||||||
const historyData = [
|
const historyData = [
|
||||||
@ -48,10 +57,29 @@ describe('HistoryTab Master Suite', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
fetchTradeProfilesMock.mockResolvedValue([{ id: 'p1', name: 'Alpha' }]);
|
authState.user = { id: 'u1' };
|
||||||
(supabase.from as any).mockImplementation((table: string) => {
|
authState.profile = { role: 'user' };
|
||||||
if (table === tableNameTransactions) return mockSupabaseChain(historyData);
|
authState.session = { access_token: 'session-token' };
|
||||||
return mockSupabaseChain([]);
|
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: 1000,
|
||||||
pnl_percent: 2,
|
pnl_percent: 2,
|
||||||
created_at: `2024-01-01T12:${String(i % 60).padStart(2, '0')}:00`,
|
created_at: `2024-01-01T12:${String(i % 60).padStart(2, '0')}:00`,
|
||||||
|
|
||||||
side: 'BUY',
|
side: 'BUY',
|
||||||
source: 'BOT',
|
source: 'BOT',
|
||||||
profile_id: 'p1',
|
profile_id: 'p1',
|
||||||
size: 1,
|
size: 1,
|
||||||
reason: 'Trend'
|
reason: 'Trend',
|
||||||
|
trade_id: `TRD-${i}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
(supabase.from as any).mockImplementation((table: string) => {
|
fetchTradeHistoryMock.mockResolvedValue(manyRecords);
|
||||||
if (table === tableNameTransactions) return mockSupabaseChain(manyRecords);
|
fetchPositionsBootstrapMock.mockResolvedValue({
|
||||||
return mockSupabaseChain([]);
|
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 />);
|
const { container } = render(<HistoryTab />);
|
||||||
await screen.findByText(/Page 1 \/ 2/i, {}, { timeout: 5000 });
|
await screen.findByText(/Page 1 \/ 2/i, {}, { timeout: 5000 });
|
||||||
|
|
||||||
|
|
||||||
const dateInputs = container.querySelectorAll('input[type="date"]');
|
const dateInputs = container.querySelectorAll('input[type="date"]');
|
||||||
if (dateInputs.length >= 2) {
|
if (dateInputs.length >= 2) {
|
||||||
fireEvent.change(dateInputs[0], { target: { value: '2024-01-01' } });
|
fireEvent.change(dateInputs[0], { target: { value: '2024-01-01' } });
|
||||||
@ -111,17 +154,13 @@ describe('HistoryTab Master Suite', () => {
|
|||||||
await user.click(prevBtn);
|
await user.click(prevBtn);
|
||||||
await screen.findByText(/Page 1 \/ 2/i);
|
await screen.findByText(/Page 1 \/ 2/i);
|
||||||
|
|
||||||
|
|
||||||
await user.click(screen.getByText(/Clear/i));
|
await user.click(screen.getByText(/Clear/i));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('covers empty fallback and source mapping', async () => {
|
it('covers empty fallback and source mapping', async () => {
|
||||||
(supabase.from as any).mockImplementation((table: string) => {
|
fetchTradeHistoryMock.mockResolvedValue([
|
||||||
if (table === tableNameTransactions) return mockSupabaseChain([
|
{ ...historyData[0], source: 'MANUAL', profile_id: null }
|
||||||
{ ...historyData[0], source: 'MANUAL', profile_id: null }
|
]);
|
||||||
]);
|
|
||||||
return mockSupabaseChain([]);
|
|
||||||
});
|
|
||||||
render(<HistoryTab />);
|
render(<HistoryTab />);
|
||||||
await waitFor(() => expect(screen.queryByText(/MANUAL/i)).toBeInTheDocument());
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { PositionsTab } from './PositionsTab';
|
import { PositionsTab } from './PositionsTab';
|
||||||
import type { BotState } from '../hooks/useWebSocket';
|
import type { BotState } from '../hooks/useWebSocket';
|
||||||
import { tableNameOrders, tableNameStocks } from '../lib/const';
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
authState,
|
authState,
|
||||||
fromMock,
|
fetchPositionsBootstrapMock,
|
||||||
getSessionMock,
|
getPlatformAccessTokenMock,
|
||||||
entriesEqMock,
|
canonicalLifecycleState
|
||||||
ordersEqMock,
|
|
||||||
historyEqMock,
|
|
||||||
fetchTradeProfilesMock
|
|
||||||
} = vi.hoisted(() => ({
|
} = vi.hoisted(() => ({
|
||||||
authState: {
|
authState: {
|
||||||
user: { id: 'user-1' } as any,
|
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(),
|
fetchPositionsBootstrapMock: vi.fn(),
|
||||||
getSessionMock: vi.fn(),
|
getPlatformAccessTokenMock: vi.fn(),
|
||||||
entriesEqMock: vi.fn(),
|
canonicalLifecycleState: {
|
||||||
ordersEqMock: vi.fn(),
|
snapshot: {
|
||||||
historyEqMock: vi.fn(),
|
lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }],
|
||||||
fetchTradeProfilesMock: vi.fn()
|
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', () => ({
|
vi.mock('../components/AuthContext', () => ({
|
||||||
useAuth: () => authState
|
useAuth: () => authState
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/supabaseClient', () => ({
|
vi.mock('../lib/positionsApi', () => ({
|
||||||
supabase: {
|
fetchPositionsBootstrap: fetchPositionsBootstrapMock
|
||||||
from: fromMock,
|
|
||||||
auth: {
|
|
||||||
getSession: getSessionMock
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../lib/profileApi', () => ({
|
vi.mock('../lib/authSession', () => ({
|
||||||
fetchTradeProfiles: fetchTradeProfilesMock
|
getPlatformAccessToken: getPlatformAccessTokenMock
|
||||||
}));
|
}));
|
||||||
|
|
||||||
interface QueryResult {
|
vi.mock('../hooks/useCanonicalLifecycle', () => ({
|
||||||
data: any;
|
useCanonicalLifecycle: () => ({
|
||||||
error: any;
|
snapshot: canonicalLifecycleState.snapshot,
|
||||||
}
|
loading: false,
|
||||||
|
error: null
|
||||||
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());
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildBotState = (now: number): BotState => {
|
const buildBotState = (now: number): BotState => {
|
||||||
const sharedSymbolMeta = {
|
const sharedSymbolMeta = {
|
||||||
@ -99,7 +76,7 @@ const buildBotState = (now: number): BotState => {
|
|||||||
indicators: {}
|
indicators: {}
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const botOrders = Array.from({ length: 24 }).map((_, idx) => ({
|
const botOrders = Array.from({ length: 12 }).map((_, idx) => ({
|
||||||
id: `bot-order-${idx}`,
|
id: `bot-order-${idx}`,
|
||||||
order_id: `bot-order-${idx}`,
|
order_id: `bot-order-${idx}`,
|
||||||
profileId: idx % 2 === 0 ? 'p1' : 'p2',
|
profileId: idx % 2 === 0 ? 'p1' : 'p2',
|
||||||
@ -176,7 +153,7 @@ const buildBotState = (now: number): BotState => {
|
|||||||
tradeId: 'TRD-POS-1'
|
tradeId: 'TRD-POS-1'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'missing-trade',
|
id: 'manual-gap',
|
||||||
symbol: 'ETH/USDT',
|
symbol: 'ETH/USDT',
|
||||||
side: 'BUY',
|
side: 'BUY',
|
||||||
size: 1,
|
size: 1,
|
||||||
@ -187,56 +164,8 @@ const buildBotState = (now: number): BotState => {
|
|||||||
unrealizedPnl: 200,
|
unrealizedPnl: 200,
|
||||||
unrealizedPnlPercent: 10,
|
unrealizedPnlPercent: 10,
|
||||||
marketValue: 2200,
|
marketValue: 2200,
|
||||||
profileId: 'p1',
|
profileId: 'p2',
|
||||||
profileName: 'High Risk Scalper'
|
profileName: 'Conservative Bag'
|
||||||
},
|
|
||||||
{
|
|
||||||
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'
|
|
||||||
}
|
}
|
||||||
] as any,
|
] as any,
|
||||||
orders: botOrders as any,
|
orders: botOrders as any,
|
||||||
@ -257,17 +186,30 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
authState.user = { id: 'user-1' };
|
authState.user = { id: 'user-1' };
|
||||||
authState.profile = { role: 'admin' };
|
authState.profile = { role: 'admin' };
|
||||||
fromMock.mockReset();
|
authState.session = { access_token: 'session-token' };
|
||||||
getSessionMock.mockReset();
|
fetchPositionsBootstrapMock.mockReset();
|
||||||
entriesEqMock.mockReset();
|
getPlatformAccessTokenMock.mockReset();
|
||||||
ordersEqMock.mockReset();
|
canonicalLifecycleState.snapshot = {
|
||||||
historyEqMock.mockReset();
|
lifecycleRows: [{ tradeId: 'TRD-CANONICAL-1' }],
|
||||||
fetchTradeProfilesMock.mockReset();
|
realizedTrades: [],
|
||||||
fetchTradeProfilesMock.mockResolvedValue([
|
openPositions: [{
|
||||||
{ id: 'p1', name: 'High Risk Scalper' },
|
id: 'canonical-open-1',
|
||||||
{ id: 'p2', name: 'Conservative Bag' }
|
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('confirm', vi.fn(() => true));
|
||||||
vi.stubGlobal('alert', vi.fn());
|
vi.stubGlobal('alert', vi.fn());
|
||||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
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 now = Date.now();
|
||||||
const dbOrders = [
|
fetchPositionsBootstrapMock.mockResolvedValue({
|
||||||
{
|
entries: [
|
||||||
id: 'db-merge',
|
{
|
||||||
order_id: 'merge-1',
|
stock_instance_id: 'manual-live',
|
||||||
profile_id: 'p1',
|
symbol: 'BTC/USDT',
|
||||||
symbol: 'BTC/USDT',
|
quantity: 1,
|
||||||
type: 'Market',
|
buy_price: 100,
|
||||||
side: 'BUY',
|
drop_threshold_for_buy: 95,
|
||||||
qty: 1.2,
|
gain_threshold_for_sell: 115,
|
||||||
price: 140,
|
active: true,
|
||||||
status: 'pending_new',
|
status: 'active'
|
||||||
timestamp: new Date(now - 20_000).toISOString(),
|
}
|
||||||
created_at: new Date(now - 20_000).toISOString(),
|
],
|
||||||
trade_id: 'TRD-MERGE',
|
orders: [
|
||||||
action: 'ENTRY',
|
{
|
||||||
source: 'BOT'
|
id: 'db-profile',
|
||||||
},
|
order_id: 'db-profile',
|
||||||
{
|
profile_id: 'p2',
|
||||||
id: 'db-profile',
|
symbol: 'BTC/USDT',
|
||||||
order_id: 'db-profile',
|
type: 'Market',
|
||||||
profile_id: 'p2',
|
side: 'BUY',
|
||||||
symbol: 'BTC/USDT',
|
qty: 1,
|
||||||
type: 'Market',
|
price: 2000,
|
||||||
side: 'BUY',
|
status: 'filled',
|
||||||
qty: 1,
|
timestamp: now - 120_000,
|
||||||
price: 2000,
|
created_at: new Date(now - 120_000).toISOString(),
|
||||||
status: 'filled',
|
trade_id: 'TRD-PROFILE-MISMATCH',
|
||||||
timestamp: now - 120_000,
|
action: 'ENTRY',
|
||||||
created_at: new Date(now - 120_000).toISOString(),
|
source: 'BOT'
|
||||||
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: 'db-symbol',
|
{ id: 'p2', name: 'Conservative Bag' }
|
||||||
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
|
|
||||||
}]
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@ -374,83 +263,54 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('Order Activity')).toBeInTheDocument();
|
expect(screen.getByText('Order Activity')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Lifecycle Trace')).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(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'all', limit: 5000 });
|
||||||
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(screen.getAllByText('Conservative Bag').length).toBeGreaterThan(0);
|
expect(screen.getAllByText('Conservative Bag').length).toBeGreaterThan(0);
|
||||||
|
expect(screen.getByText(/Lifecycle Mismatch Diagnostics/)).toBeInTheDocument();
|
||||||
const initialNextButtons = screen.getAllByRole('button', { name: 'Next' });
|
expect(screen.queryByText('No active positions for this selection.')).not.toBeInTheDocument();
|
||||||
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' }));
|
|
||||||
|
|
||||||
const selects = container.querySelectorAll('select');
|
const selects = container.querySelectorAll('select');
|
||||||
expect(selects.length).toBeGreaterThanOrEqual(2);
|
expect(selects.length).toBeGreaterThanOrEqual(2);
|
||||||
await user.selectOptions(selects[0] as HTMLSelectElement, 'p1');
|
await user.selectOptions(selects[0] as HTMLSelectElement, 'p1');
|
||||||
await user.selectOptions(selects[1] as HTMLSelectElement, 'p1');
|
await user.selectOptions(selects[1] as HTMLSelectElement, 'p1');
|
||||||
|
|
||||||
const dateInputs = container.querySelectorAll('input[type="date"]');
|
const nextButtons = screen.getAllByRole('button', { name: 'Next' });
|
||||||
expect(dateInputs.length).toBeGreaterThanOrEqual(4);
|
const prevButtons = screen.getAllByRole('button', { name: 'Prev' });
|
||||||
await user.type(dateInputs[0] as HTMLInputElement, '2026-02-15');
|
await user.click(nextButtons[0]);
|
||||||
await user.type(dateInputs[1] as HTMLInputElement, '2026-02-16');
|
await user.click(prevButtons[0]);
|
||||||
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);
|
|
||||||
|
|
||||||
it('handles square-off workflow for cancel, auth failure, api failure, and success', async () => {
|
it('handles square-off workflow for cancel, auth failure, api failure, and success', async () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
configureQueries({
|
fetchPositionsBootstrapMock.mockResolvedValue({
|
||||||
entries: [{ data: [], error: null }],
|
entries: [],
|
||||||
orders: [{
|
orders: [{
|
||||||
data: [{
|
id: 'entry-order',
|
||||||
id: 'entry-order',
|
order_id: 'entry-order',
|
||||||
order_id: 'entry-order',
|
profile_id: 'p1',
|
||||||
profile_id: 'p1',
|
symbol: 'BTC/USDT',
|
||||||
symbol: 'BTC/USDT',
|
type: 'Market',
|
||||||
type: 'Market',
|
side: 'BUY',
|
||||||
side: 'BUY',
|
qty: 1,
|
||||||
qty: 1,
|
price: 100,
|
||||||
price: 100,
|
status: 'filled',
|
||||||
status: 'filled',
|
timestamp: now - 1_000,
|
||||||
timestamp: now - 1_000,
|
created_at: new Date(now - 1_000).toISOString(),
|
||||||
created_at: new Date(now - 1_000).toISOString(),
|
trade_id: 'TRD-POS-1',
|
||||||
trade_id: 'TRD-POS-1',
|
action: 'ENTRY',
|
||||||
action: 'ENTRY',
|
source: 'BOT'
|
||||||
source: 'BOT'
|
|
||||||
}],
|
|
||||||
error: null
|
|
||||||
}],
|
}],
|
||||||
trade_history: [{ data: [], error: null }],
|
historyTradeKeys: [],
|
||||||
|
profiles: [{ id: 'p1', name: 'High Risk Scalper' }]
|
||||||
});
|
});
|
||||||
|
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
render(<PositionsTab botState={buildBotState(now)} />);
|
render(<PositionsTab botState={buildBotState(now)} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'High Risk Scalper' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getAllByRole('button', { name: 'Square Off' }).length).toBeGreaterThan(0);
|
expect(screen.getAllByRole('button', { name: 'Square Off' }).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
@ -461,10 +321,10 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
.mockReturnValueOnce(true)
|
.mockReturnValueOnce(true)
|
||||||
.mockReturnValueOnce(true);
|
.mockReturnValueOnce(true);
|
||||||
|
|
||||||
getSessionMock
|
getPlatformAccessTokenMock
|
||||||
.mockResolvedValueOnce({ data: { session: null } })
|
.mockRejectedValueOnce(new Error('Not authenticated'))
|
||||||
.mockResolvedValueOnce({ data: { session: { access_token: 'token-1' } } })
|
.mockResolvedValueOnce('token-1')
|
||||||
.mockResolvedValueOnce({ data: { session: { access_token: 'token-2' } } });
|
.mockResolvedValueOnce('token-2');
|
||||||
|
|
||||||
vi.mocked(fetch)
|
vi.mocked(fetch)
|
||||||
.mockResolvedValueOnce({ json: async () => ({ success: false, error: 'broker rejected' }) } as any)
|
.mockResolvedValueOnce({ json: async () => ({ success: false, error: 'broker rejected' }) } as any)
|
||||||
@ -488,24 +348,14 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(alert).toHaveBeenCalledWith(expect.stringContaining('Successfully closed'));
|
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.user = { id: 'user-2' };
|
||||||
authState.profile = { role: 'trader' };
|
authState.profile = { role: 'trader' };
|
||||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
canonicalLifecycleState.snapshot = null;
|
||||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||||
|
fetchPositionsBootstrapMock.mockRejectedValueOnce(new Error('bootstrap failed'));
|
||||||
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'));
|
|
||||||
|
|
||||||
render(<PositionsTab botState={{
|
render(<PositionsTab botState={{
|
||||||
symbols: {},
|
symbols: {},
|
||||||
@ -526,37 +376,10 @@ describe('PositionsTab DOM behavior', () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('No recent orders for this cluster.')).toBeInTheDocument();
|
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(fetchPositionsBootstrapMock).toHaveBeenCalledWith({ scope: 'user', limit: 5000 });
|
||||||
expect(ordersEqMock).toHaveBeenCalledWith('user_id', 'user-2');
|
expect(errorSpy).toHaveBeenCalledWith('[PositionsTab] Failed loading positions bootstrap:', 'bootstrap failed');
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1366,10 +1366,10 @@ export const PositionsTab = ({ botState }: PositionsTabProps) => {
|
|||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
|
if (!confirm(`Are you sure you want to CLOSE ${pos.symbol}?`)) return;
|
||||||
try {
|
try {
|
||||||
const accessToken = getPlatformAccessToken();
|
const accessToken = await getPlatformAccessToken();
|
||||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/close`, {
|
const response = await fetch(`${tradingRuntime.tradingApiUrl}/api/close`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${accessToken}`
|
'Authorization': `Bearer ${accessToken}`
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user