feat: move manual entries behind backend api
This commit is contained in:
parent
44f3171783
commit
ebaabaed47
@ -23,6 +23,11 @@ import {
|
||||
saveCurrentUserProfile,
|
||||
saveTradeProfileForUser,
|
||||
} from './profileRepository.js';
|
||||
import {
|
||||
deleteManualEntryForUser,
|
||||
listManualEntriesForUser,
|
||||
saveManualEntryForUser
|
||||
} from './manualEntryRepository.js';
|
||||
import { mergeOrderSnapshots, mergePositionSnapshots } from './stateMerge.js';
|
||||
import { OperationalEvent } from '../domain/operationalEvents.js';
|
||||
import { runBacktest } from '../backtest/index.js';
|
||||
@ -1799,6 +1804,69 @@ export class ApiServer {
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/api/manual-entries', this.requireAuth, async (req, res) => {
|
||||
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||
if (!authUserId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = await listManualEntriesForUser(authUserId, supabaseService);
|
||||
res.json({ entries });
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ error: `Failed to load manual entries: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.post('/api/manual-entries', this.requireAuth, async (req, res) => {
|
||||
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||
if (!authUserId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await saveManualEntryForUser(authUserId, req.body || {}, supabaseService);
|
||||
res.status(201).json({ entry });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: `Failed to save manual entry: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.put('/api/manual-entries/:id', this.requireAuth, async (req, res) => {
|
||||
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||
if (!authUserId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const entry = await saveManualEntryForUser(authUserId, {
|
||||
...(req.body || {}),
|
||||
stock_instance_id: String(req.params.id || '').trim()
|
||||
}, supabaseService);
|
||||
res.json({ entry });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: `Failed to update manual entry: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.delete('/api/manual-entries/:id', this.requireAuth, async (req, res) => {
|
||||
const authUserId = (req as AuthenticatedRequest).authUserId;
|
||||
if (!authUserId) {
|
||||
res.status(401).json({ error: 'Unauthorized' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteManualEntryForUser(authUserId, String(req.params.id || '').trim(), supabaseService);
|
||||
res.json({ success: true });
|
||||
} catch (error: any) {
|
||||
res.status(400).json({ error: `Failed to delete manual entry: ${error.message}` });
|
||||
}
|
||||
});
|
||||
|
||||
this.app.get('/api/admin/config/dynamic', this.requireAuth, this.requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const items = await listDynamicConfigEntries(supabaseService);
|
||||
|
||||
188
backend/src/services/manualEntryRepository.ts
Normal file
188
backend/src/services/manualEntryRepository.ts
Normal file
@ -0,0 +1,188 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import logger from '../utils/logger.js';
|
||||
import type { supabaseService } from './SupabaseService.js';
|
||||
|
||||
type LegacySupabaseService = typeof supabaseService;
|
||||
|
||||
export interface ManualEntryRecord {
|
||||
stock_instance_id: string;
|
||||
symbol: string;
|
||||
active: boolean;
|
||||
user_id: string;
|
||||
buy_price?: number | null;
|
||||
sell_price?: number | null;
|
||||
buy_time?: string | null;
|
||||
sell_time?: string | null;
|
||||
quantity?: number | null;
|
||||
filled_quantity?: number | null;
|
||||
notes?: string | null;
|
||||
status: string;
|
||||
is_crypto: boolean;
|
||||
is_real_trade: boolean;
|
||||
label?: string | null;
|
||||
entry_price?: number | null;
|
||||
gain_threshold_for_sell?: number | null;
|
||||
drop_threshold_for_buy?: number | null;
|
||||
}
|
||||
|
||||
function getClient(legacyService?: LegacySupabaseService) {
|
||||
const client = legacyService?.getClient?.();
|
||||
if (!client) {
|
||||
throw new Error('Manual entry store is not configured');
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
function normalizeNullableNumber(value: unknown): number | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null || value === '') return null;
|
||||
const num = Number(value);
|
||||
return Number.isFinite(num) ? num : null;
|
||||
}
|
||||
|
||||
function normalizeNullableString(value: unknown): string | null | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
if (value === null) return null;
|
||||
const text = String(value).trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
function normalizeEntry(userId: string, input: Partial<ManualEntryRecord>, existing?: ManualEntryRecord | null): ManualEntryRecord {
|
||||
return {
|
||||
stock_instance_id: String(input.stock_instance_id || existing?.stock_instance_id || randomUUID()),
|
||||
symbol: String(input.symbol || existing?.symbol || '').trim(),
|
||||
active: Boolean(input.active ?? existing?.active ?? true),
|
||||
user_id: userId,
|
||||
buy_price: normalizeNullableNumber(input.buy_price ?? existing?.buy_price),
|
||||
sell_price: normalizeNullableNumber(input.sell_price ?? existing?.sell_price),
|
||||
buy_time: normalizeNullableString(input.buy_time ?? existing?.buy_time),
|
||||
sell_time: normalizeNullableString(input.sell_time ?? existing?.sell_time),
|
||||
quantity: normalizeNullableNumber(input.quantity ?? existing?.quantity),
|
||||
filled_quantity: normalizeNullableNumber(input.filled_quantity ?? existing?.filled_quantity),
|
||||
notes: normalizeNullableString(input.notes ?? existing?.notes),
|
||||
status: String(input.status || existing?.status || 'active'),
|
||||
is_crypto: Boolean(input.is_crypto ?? existing?.is_crypto ?? false),
|
||||
is_real_trade: Boolean(input.is_real_trade ?? existing?.is_real_trade ?? false),
|
||||
label: normalizeNullableString(input.label ?? existing?.label),
|
||||
entry_price: normalizeNullableNumber(input.entry_price ?? existing?.entry_price),
|
||||
gain_threshold_for_sell: normalizeNullableNumber(input.gain_threshold_for_sell ?? existing?.gain_threshold_for_sell),
|
||||
drop_threshold_for_buy: normalizeNullableNumber(input.drop_threshold_for_buy ?? existing?.drop_threshold_for_buy),
|
||||
};
|
||||
}
|
||||
|
||||
async function insertHistoryRecord(userId: string, entry: ManualEntryRecord, reason: 'Manual Log' | 'Manual Close', legacyService?: LegacySupabaseService): Promise<void> {
|
||||
const client = getClient(legacyService);
|
||||
const entryPrice = Number(entry.buy_price || 0);
|
||||
const exitPrice = Number(entry.sell_price || 0);
|
||||
const size = Number(entry.quantity || 0);
|
||||
if (!(entryPrice > 0) || !(exitPrice > 0) || !(size > 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pnl = (exitPrice - entryPrice) * size;
|
||||
const pnlPercent = entryPrice > 0 ? ((exitPrice - entryPrice) / entryPrice) * 100 : 0;
|
||||
|
||||
const { error } = await client
|
||||
.from('trade_history')
|
||||
.insert([{
|
||||
user_id: userId,
|
||||
symbol: entry.symbol,
|
||||
side: 'BUY',
|
||||
entry_price: entryPrice,
|
||||
exit_price: exitPrice,
|
||||
size,
|
||||
pnl,
|
||||
pnl_percent: pnlPercent,
|
||||
source: 'MANUAL',
|
||||
reason,
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
|
||||
if (error) {
|
||||
logger.warn(`[ManualEntries] Failed to insert history row: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function listManualEntriesForUser(userId: string, legacyService?: LegacySupabaseService): Promise<ManualEntryRecord[]> {
|
||||
const client = getClient(legacyService);
|
||||
const { data, error } = await client
|
||||
.from('stocks')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return Array.isArray(data) ? data as ManualEntryRecord[] : [];
|
||||
}
|
||||
|
||||
export async function saveManualEntryForUser(
|
||||
userId: string,
|
||||
input: Partial<ManualEntryRecord>,
|
||||
legacyService?: LegacySupabaseService
|
||||
): Promise<ManualEntryRecord> {
|
||||
const client = getClient(legacyService);
|
||||
const entryId = String(input.stock_instance_id || '').trim();
|
||||
let existing: ManualEntryRecord | null = null;
|
||||
|
||||
if (entryId) {
|
||||
const { data, error } = await client
|
||||
.from('stocks')
|
||||
.select('*')
|
||||
.eq('stock_instance_id', entryId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
existing = data as ManualEntryRecord | null;
|
||||
}
|
||||
|
||||
const normalized = normalizeEntry(userId, input, existing);
|
||||
if (!normalized.symbol) {
|
||||
throw new Error('Symbol is required');
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const { error } = await client
|
||||
.from('stocks')
|
||||
.update(normalized)
|
||||
.eq('stock_instance_id', normalized.stock_instance_id)
|
||||
.eq('user_id', userId);
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (normalized.sell_price && !existing.sell_price && normalized.buy_price) {
|
||||
await insertHistoryRecord(userId, normalized, 'Manual Close', legacyService);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const { error } = await client
|
||||
.from('stocks')
|
||||
.insert([normalized]);
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
if (normalized.sell_price && normalized.buy_price) {
|
||||
await insertHistoryRecord(userId, normalized, 'Manual Log', legacyService);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export async function deleteManualEntryForUser(userId: string, entryId: string, legacyService?: LegacySupabaseService): Promise<void> {
|
||||
const client = getClient(legacyService);
|
||||
const { error } = await client
|
||||
.from('stocks')
|
||||
.delete()
|
||||
.eq('stock_instance_id', entryId)
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
}
|
||||
@ -3,23 +3,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { EntryForm } from './EntryForm';
|
||||
import { tableNameStocks } from '../lib/const';
|
||||
|
||||
const {
|
||||
getSessionMock,
|
||||
stocksInsertMock,
|
||||
stocksUpdateEqMock,
|
||||
stocksUpdateMock,
|
||||
historyInsertMock,
|
||||
fromMock,
|
||||
createManualEntryMock,
|
||||
updateManualEntryMock,
|
||||
authMock
|
||||
} = vi.hoisted(() => ({
|
||||
getSessionMock: vi.fn(),
|
||||
stocksInsertMock: vi.fn(),
|
||||
stocksUpdateEqMock: vi.fn(),
|
||||
stocksUpdateMock: vi.fn(),
|
||||
historyInsertMock: vi.fn(),
|
||||
fromMock: vi.fn(),
|
||||
createManualEntryMock: vi.fn(),
|
||||
updateManualEntryMock: vi.fn(),
|
||||
authMock: { user: { id: 'user-1' } as any }
|
||||
}));
|
||||
|
||||
@ -31,48 +24,29 @@ vi.mock('../lib/supabaseClient', () => ({
|
||||
supabase: {
|
||||
auth: {
|
||||
getSession: getSessionMock
|
||||
},
|
||||
from: fromMock
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../lib/manualEntriesApi', () => ({
|
||||
createManualEntry: createManualEntryMock,
|
||||
updateManualEntry: updateManualEntryMock
|
||||
}));
|
||||
|
||||
describe('EntryForm DOM flow', () => {
|
||||
const alertMock = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
stocksInsertMock.mockReset();
|
||||
stocksUpdateEqMock.mockReset();
|
||||
stocksUpdateMock.mockReset();
|
||||
historyInsertMock.mockReset();
|
||||
createManualEntryMock.mockReset();
|
||||
updateManualEntryMock.mockReset();
|
||||
getSessionMock.mockReset();
|
||||
fromMock.mockReset();
|
||||
authMock.user = { id: 'user-1' };
|
||||
|
||||
stocksInsertMock.mockResolvedValue({ error: null });
|
||||
stocksUpdateEqMock.mockResolvedValue({ error: null });
|
||||
stocksUpdateMock.mockReturnValue({ eq: stocksUpdateEqMock });
|
||||
historyInsertMock.mockResolvedValue({ error: null });
|
||||
createManualEntryMock.mockResolvedValue({});
|
||||
updateManualEntryMock.mockResolvedValue({});
|
||||
getSessionMock.mockResolvedValue({ data: { session: null } });
|
||||
|
||||
fromMock.mockImplementation((table: string) => {
|
||||
if (table === tableNameStocks) {
|
||||
return {
|
||||
insert: stocksInsertMock,
|
||||
update: stocksUpdateMock
|
||||
};
|
||||
}
|
||||
if (table === 'trade_history') {
|
||||
return {
|
||||
insert: historyInsertMock
|
||||
};
|
||||
}
|
||||
return {
|
||||
insert: vi.fn().mockResolvedValue({ error: null }),
|
||||
update: vi.fn(() => ({ eq: vi.fn().mockResolvedValue({ error: null }) }))
|
||||
};
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
vi.stubGlobal('alert', alertMock);
|
||||
@ -91,7 +65,7 @@ describe('EntryForm DOM flow', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(stocksInsertMock).toHaveBeenCalled();
|
||||
expect(createManualEntryMock).toHaveBeenCalled();
|
||||
expect(onSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@ -127,13 +101,17 @@ describe('EntryForm DOM flow', () => {
|
||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(historyInsertMock).toHaveBeenCalledTimes(1);
|
||||
expect(createManualEntryMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(historyInsertMock.mock.calls[0][0][0].reason).toBe('Manual Log');
|
||||
expect(createManualEntryMock.mock.calls[0][0]).toEqual(expect.objectContaining({
|
||||
symbol: 'BTC/USD',
|
||||
buy_price: 100,
|
||||
sell_price: 110
|
||||
}));
|
||||
});
|
||||
|
||||
it('handles database error by alerting user', async () => {
|
||||
stocksInsertMock.mockResolvedValue({ error: { message: 'DB Error' } });
|
||||
createManualEntryMock.mockRejectedValueOnce(new Error('DB Error'));
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<EntryForm onSuccess={vi.fn()} />);
|
||||
@ -152,6 +130,6 @@ describe('EntryForm DOM flow', () => {
|
||||
await user.type(screen.getByPlaceholderText('BTC/USD'), 'BTC/USD');
|
||||
await user.click(screen.getByRole('button', { name: 'Add' }));
|
||||
|
||||
expect(stocksInsertMock).not.toHaveBeenCalled();
|
||||
expect(createManualEntryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
|
||||
import type { FormEvent } from 'react';
|
||||
import { supabase } from '../lib/supabaseClient';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { tableNameStocks } from '../lib/const';
|
||||
import { tradingRuntime } from '../lib/runtime';
|
||||
import { createManualEntry, updateManualEntry } from '../lib/manualEntriesApi';
|
||||
|
||||
interface EntryFormProps {
|
||||
onSuccess: () => void;
|
||||
@ -143,88 +143,13 @@ export function EntryForm({ onSuccess, initialData }: EntryFormProps) {
|
||||
throw new Error(`Execution Failed: ${resData.error}`);
|
||||
}
|
||||
|
||||
// If success, we continue to save to DB as usual for journaling
|
||||
alert(`✅ Trade Executed! Order ID: ${resData.orderId}`);
|
||||
}
|
||||
|
||||
if (initialData?.stock_instance_id) {
|
||||
// Update
|
||||
const { error } = await supabase
|
||||
.from(tableNameStocks)
|
||||
.update(payload)
|
||||
.eq('stock_instance_id', initialData.stock_instance_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// --- NEW: Log to Trade History if Closing ---
|
||||
// If we are adding a sell_price to an open position, we consider it "Closing"
|
||||
if (payload.sell_price && !initialData.sell_price && payload.buy_price) {
|
||||
const entryPrice = payload.buy_price;
|
||||
const exitPrice = payload.sell_price;
|
||||
const size = payload.quantity || 0;
|
||||
const pnl = (exitPrice - entryPrice) * size;
|
||||
const pnlPercent = ((exitPrice - entryPrice) / entryPrice) * 100;
|
||||
|
||||
// Insert into trade_history
|
||||
const { error: historyError } = await supabase
|
||||
.from('trade_history')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
symbol: payload.symbol,
|
||||
side: 'BUY',
|
||||
entry_price: entryPrice,
|
||||
exit_price: exitPrice,
|
||||
size: size,
|
||||
pnl: pnl,
|
||||
pnl_percent: pnlPercent,
|
||||
source: 'MANUAL',
|
||||
reason: 'Manual Close',
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
|
||||
if (historyError) {
|
||||
console.error("Failed to log manual trade to history:", historyError.message);
|
||||
}
|
||||
}
|
||||
|
||||
await updateManualEntry(initialData.stock_instance_id, payload);
|
||||
} else {
|
||||
// Insert
|
||||
// Remove ID to let DB generate it
|
||||
const { error } = await supabase
|
||||
.from(tableNameStocks)
|
||||
.insert([{ ...payload, stock_instance_id: crypto.randomUUID() }]);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// --- NEW: Log to Trade History if Creating a Closed Entry (Journaling past trade) ---
|
||||
if (payload.sell_price && payload.buy_price) {
|
||||
const entryPrice = payload.buy_price;
|
||||
const exitPrice = payload.sell_price;
|
||||
const size = payload.quantity || 0;
|
||||
const pnl = (exitPrice - entryPrice) * size;
|
||||
const pnlPercent = ((exitPrice - entryPrice) / entryPrice) * 100;
|
||||
|
||||
// Insert into trade_history
|
||||
const { error: historyError } = await supabase
|
||||
.from('trade_history')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
symbol: payload.symbol,
|
||||
side: 'BUY',
|
||||
entry_price: entryPrice,
|
||||
exit_price: exitPrice,
|
||||
size: size,
|
||||
pnl: pnl,
|
||||
pnl_percent: pnlPercent,
|
||||
source: 'MANUAL',
|
||||
reason: 'Manual Log',
|
||||
timestamp: Date.now()
|
||||
}]);
|
||||
|
||||
if (historyError) {
|
||||
console.error("Failed to log manual trade to history:", historyError.message);
|
||||
}
|
||||
}
|
||||
await createManualEntry({ ...payload, stock_instance_id: crypto.randomUUID() });
|
||||
}
|
||||
|
||||
onSuccess();
|
||||
|
||||
77
web/src/lib/manualEntriesApi.ts
Normal file
77
web/src/lib/manualEntriesApi.ts
Normal file
@ -0,0 +1,77 @@
|
||||
import { supabase } from './supabaseClient';
|
||||
import { tradingRuntime } from './runtime';
|
||||
|
||||
export interface ManualEntryPayload {
|
||||
stock_instance_id?: string;
|
||||
symbol: string;
|
||||
active: boolean;
|
||||
user_id?: string;
|
||||
buy_price?: number | null;
|
||||
sell_price?: number | null;
|
||||
buy_time?: string | null;
|
||||
sell_time?: string | null;
|
||||
quantity?: number | null;
|
||||
filled_quantity?: number | null;
|
||||
notes?: string | null;
|
||||
status: string;
|
||||
is_crypto: boolean;
|
||||
is_real_trade: boolean;
|
||||
label?: string | null;
|
||||
entry_price?: number | null;
|
||||
gain_threshold_for_sell?: number | null;
|
||||
drop_threshold_for_buy?: number | null;
|
||||
}
|
||||
|
||||
async function getAccessToken(): Promise<string> {
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
const accessToken = sessionData.session?.access_token;
|
||||
if (!accessToken) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const accessToken = await getAccessToken();
|
||||
const response = await fetch(`${tradingRuntime.tradingApiUrl}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
const body = await response.json().catch(() => ({} as any));
|
||||
if (!response.ok) {
|
||||
throw new Error(body?.error || `Request failed (${response.status})`);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
export async function fetchManualEntries(): Promise<ManualEntryPayload[]> {
|
||||
const response = await apiRequest<{ entries: ManualEntryPayload[] }>('/api/manual-entries');
|
||||
return Array.isArray(response.entries) ? response.entries : [];
|
||||
}
|
||||
|
||||
export async function createManualEntry(payload: ManualEntryPayload): Promise<ManualEntryPayload> {
|
||||
const response = await apiRequest<{ entry: ManualEntryPayload }>('/api/manual-entries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function updateManualEntry(id: string, payload: ManualEntryPayload): Promise<ManualEntryPayload> {
|
||||
const response = await apiRequest<{ entry: ManualEntryPayload }>(`/api/manual-entries/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.entry;
|
||||
}
|
||||
|
||||
export async function deleteManualEntry(id: string): Promise<void> {
|
||||
await apiRequest<{ success: boolean }>(`/api/manual-entries/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
@ -1,32 +1,35 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { EntriesTab } from './EntriesTab';
|
||||
import { tableNameStocks } from '../lib/const';
|
||||
|
||||
const {
|
||||
authState,
|
||||
fromMock,
|
||||
confirmMock
|
||||
} = vi.hoisted(() => ({
|
||||
authState: {
|
||||
user: { id: 'user-1', email: 'test@demo.test' } as any,
|
||||
profile: { role: 'user' } as any
|
||||
},
|
||||
fromMock: vi.fn(),
|
||||
confirmMock: vi.fn()
|
||||
}));
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { EntriesTab } from './EntriesTab';
|
||||
|
||||
const {
|
||||
authState,
|
||||
confirmMock,
|
||||
fetchManualEntriesMock,
|
||||
deleteManualEntryMock,
|
||||
createManualEntryMock
|
||||
} = vi.hoisted(() => ({
|
||||
authState: {
|
||||
user: { id: 'user-1', email: 'test@demo.test' } as any,
|
||||
profile: { role: 'user' } as any
|
||||
},
|
||||
confirmMock: vi.fn(),
|
||||
fetchManualEntriesMock: vi.fn(),
|
||||
deleteManualEntryMock: vi.fn(),
|
||||
createManualEntryMock: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../components/AuthContext', () => ({
|
||||
useAuth: () => authState
|
||||
}));
|
||||
|
||||
vi.mock('../lib/supabaseClient', () => ({
|
||||
supabase: {
|
||||
from: fromMock
|
||||
}
|
||||
}));
|
||||
vi.mock('../lib/manualEntriesApi', () => ({
|
||||
fetchManualEntries: fetchManualEntriesMock,
|
||||
deleteManualEntry: deleteManualEntryMock,
|
||||
createManualEntry: createManualEntryMock
|
||||
}));
|
||||
|
||||
vi.mock('../components/EntryForm', () => ({
|
||||
EntryForm: ({ onSuccess, initialData }: any) => (
|
||||
@ -37,21 +40,7 @@ vi.mock('../components/EntryForm', () => ({
|
||||
)
|
||||
}));
|
||||
|
||||
const createMockQuery = (data: any, error: any = null) => {
|
||||
const chain: any = {
|
||||
select: vi.fn(),
|
||||
eq: vi.fn(),
|
||||
order: vi.fn(),
|
||||
then: vi.fn()
|
||||
};
|
||||
chain.select.mockReturnValue(chain);
|
||||
chain.eq.mockReturnValue(chain);
|
||||
chain.order.mockReturnValue(chain);
|
||||
chain.then.mockImplementation((cb: any) => Promise.resolve({ data, error }).then(cb));
|
||||
return chain;
|
||||
};
|
||||
|
||||
describe('EntriesTab master suite', () => {
|
||||
describe('EntriesTab master suite', () => {
|
||||
const mockEntry = {
|
||||
stock_instance_id: 'entry-1',
|
||||
symbol: 'BTC/USDT',
|
||||
@ -75,26 +64,16 @@ describe('EntriesTab master suite', () => {
|
||||
settings: { executionMode: 'Paper' }
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authState.user = { id: 'user-1', email: 'test@demo.test' };
|
||||
authState.profile = { role: 'user' };
|
||||
vi.stubGlobal('confirm', confirmMock);
|
||||
vi.stubGlobal('crypto', { randomUUID: () => 'uuid-123' });
|
||||
|
||||
fromMock.mockImplementation((table: string) => {
|
||||
if (table === tableNameStocks) {
|
||||
return {
|
||||
select: () => createMockQuery([mockEntry]),
|
||||
delete: () => ({ eq: () => Promise.resolve({ error: null }) }),
|
||||
insert: () => Promise.resolve({ error: null })
|
||||
};
|
||||
}
|
||||
return {
|
||||
select: () => createMockQuery([])
|
||||
};
|
||||
});
|
||||
});
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authState.user = { id: 'user-1', email: 'test@demo.test' };
|
||||
authState.profile = { role: 'user' };
|
||||
vi.stubGlobal('confirm', confirmMock);
|
||||
vi.stubGlobal('crypto', { randomUUID: () => 'uuid-123' });
|
||||
fetchManualEntriesMock.mockResolvedValue([mockEntry]);
|
||||
deleteManualEntryMock.mockResolvedValue(undefined);
|
||||
createManualEntryMock.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('handles entry drawer interactions', async () => {
|
||||
const user = userEvent.setup();
|
||||
@ -109,14 +88,14 @@ describe('EntriesTab master suite', () => {
|
||||
|
||||
it('derives diverse entry states (LOCKED, BLOCKED, SUBMITTED, CONFIRMED, ORPHAN)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const entries = [
|
||||
{ stock_instance_id: 's1', symbol: 'S1', active: true, status: 'active', is_real_trade: false },
|
||||
{ stock_instance_id: 's2', symbol: 'S2', active: false, status: 'cooldown', is_real_trade: false }, // Use 'cooldown' instead of 'blocked' because 'blocked' contains 'lock'
|
||||
const entries = [
|
||||
{ stock_instance_id: 's1', symbol: 'S1', active: true, status: 'active', is_real_trade: false },
|
||||
{ stock_instance_id: 's2', symbol: 'S2', active: false, status: 'cooldown', is_real_trade: false }, // Use 'cooldown' instead of 'blocked' because 'blocked' contains 'lock'
|
||||
{ stock_instance_id: 's3', symbol: 'S3', active: false, status: 'submitted', is_real_trade: false },
|
||||
{ stock_instance_id: 's4', symbol: 'S4', active: false, status: 'confirmed', is_real_trade: false },
|
||||
{ stock_instance_id: 's5', symbol: 'S5', active: false, status: 'orphan', is_real_trade: false },
|
||||
];
|
||||
fromMock.mockImplementation(() => ({ select: () => createMockQuery(entries) }));
|
||||
{ stock_instance_id: 's5', symbol: 'S5', active: false, status: 'orphan', is_real_trade: false },
|
||||
];
|
||||
fetchManualEntriesMock.mockResolvedValue(entries);
|
||||
|
||||
render(<EntriesTab botState={mockBotState} />);
|
||||
|
||||
@ -138,13 +117,13 @@ describe('EntriesTab master suite', () => {
|
||||
await waitFor(() => expect(screen.getByText('BTC/USDT')).toBeInTheDocument());
|
||||
|
||||
const card = screen.getByText('BTC/USDT').closest('.group');
|
||||
const buttons = within(card as HTMLElement).getAllByRole('button');
|
||||
|
||||
await fireEvent.click(buttons[1]);
|
||||
expect(fromMock).toHaveBeenCalledWith(tableNameStocks);
|
||||
|
||||
confirmMock.mockReturnValue(true);
|
||||
await fireEvent.click(buttons[2]);
|
||||
expect(fromMock).toHaveBeenCalledWith(tableNameStocks);
|
||||
});
|
||||
});
|
||||
const buttons = within(card as HTMLElement).getAllByRole('button');
|
||||
|
||||
await fireEvent.click(buttons[1]);
|
||||
expect(createManualEntryMock).toHaveBeenCalled();
|
||||
|
||||
confirmMock.mockReturnValue(true);
|
||||
await fireEvent.click(buttons[2]);
|
||||
expect(deleteManualEntryMock).toHaveBeenCalledWith('entry-1');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '../lib/supabaseClient';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { tableNameStocks } from '../lib/const';
|
||||
import { EntryForm } from '../components/EntryForm';
|
||||
import { Pencil, Trash2, Copy as CopyIcon, Search, Plus, Filter, LayoutGrid, Clock, ShieldCheck, Activity } from 'lucide-react';
|
||||
import type { BotState } from '../hooks/useWebSocket';
|
||||
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../components/AuthContext';
|
||||
import { EntryForm } from '../components/EntryForm';
|
||||
import { Pencil, Trash2, Copy as CopyIcon, Search, Plus, Filter, LayoutGrid, Clock, ShieldCheck, Activity } from 'lucide-react';
|
||||
import type { BotState } from '../hooks/useWebSocket';
|
||||
import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket';
|
||||
import { createManualEntry, deleteManualEntry, fetchManualEntries, type ManualEntryPayload } from '../lib/manualEntriesApi';
|
||||
|
||||
interface Entry {
|
||||
stock_instance_id: string;
|
||||
@ -105,20 +104,14 @@ export const EntriesTab = ({ botState = DEFAULT_BOT_STATE }: EntriesTabProps) =>
|
||||
// Filter Logic
|
||||
const filteredEntries = filterEntriesByTab(entries, activeTab);
|
||||
|
||||
const fetchEntries = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from(tableNameStocks)
|
||||
.select("*")
|
||||
.eq("user_id", user.id)
|
||||
.order("created_at", { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setEntries(data || []);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error fetching entries:", message);
|
||||
const fetchEntries = async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
const data = await fetchManualEntries();
|
||||
setEntries(data as Entry[]);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("Error fetching entries:", message);
|
||||
}
|
||||
};
|
||||
|
||||
@ -126,18 +119,16 @@ export const EntriesTab = ({ botState = DEFAULT_BOT_STATE }: EntriesTabProps) =>
|
||||
fetchEntries();
|
||||
}, [user]);
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('⚠️ CRITICAL: Permanently delete this entry from neural watchlist?')) return;
|
||||
await supabase.from(tableNameStocks).delete().eq('stock_instance_id', id);
|
||||
fetchEntries();
|
||||
};
|
||||
|
||||
const handleClone = async (entry: Entry) => {
|
||||
await supabase.from(tableNameStocks).insert([{
|
||||
...buildClonedEntryPayload(entry, crypto.randomUUID())
|
||||
}]);
|
||||
fetchEntries();
|
||||
};
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('⚠️ CRITICAL: Permanently delete this entry from neural watchlist?')) return;
|
||||
await deleteManualEntry(id);
|
||||
fetchEntries();
|
||||
};
|
||||
|
||||
const handleClone = async (entry: Entry) => {
|
||||
await createManualEntry(buildClonedEntryPayload(entry, crypto.randomUUID()) as unknown as ManualEntryPayload);
|
||||
fetchEntries();
|
||||
};
|
||||
|
||||
const entryCards = filteredEntries.map((entry) => {
|
||||
const entryState = deriveEntryState(entry, botState);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user