diff --git a/backend/package.json b/backend/package.json index 81f4004..ec2f11d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -30,6 +30,7 @@ "check:api-contract": "node --import tsx verifyApiContract.ts", "check:audit-repository": "node --import tsx verifyAuditRepository.ts", "check:market-data-endpoints": "node --import tsx verifyMarketDataEndpoints.ts", + "check:chat-copilot-contract": "node --import tsx verifyChatCopilotContract.ts", "check:fmp-cache": "node --import tsx testFmpCache.ts", "check:backtest-strategy-safety": "node --import tsx testBacktestStrategySafety.ts", "check:websocket-contract": "node --import tsx src/scripts/verifyWebsocketContract.ts", diff --git a/backend/verifyChatCopilotContract.ts b/backend/verifyChatCopilotContract.ts new file mode 100644 index 0000000..fd1c05d --- /dev/null +++ b/backend/verifyChatCopilotContract.ts @@ -0,0 +1,105 @@ +/** + * verifyChatCopilotContract.ts + * + * Static contract checks for the chat copilot surface. + * Verifies the supported action set, runtime-context prompt contract, + * and safe quick-link guidance without starting the backend server. + */ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const apiServerSource = readFileSync(join(__dirname, 'src/services/apiServer.ts'), 'utf8'); + +function assertSourceIncludes(fragment: string, message: string) { + assert.ok(apiServerSource.includes(fragment), message); +} + +function assertSourceMatches(pattern: RegExp, message: string) { + assert.match(apiServerSource, pattern, message); +} + +function testChatRouteRequiresAuth() { + assertSourceMatches( + /this\.app\.post\('\/api\/chat',\s*this\.requireAuth,/, + '/api/chat must be protected by requireAuth', + ); + console.log('[PASS] /api/chat requires authenticated requests.'); +} + +function testChatActionContract() { + for (const action of [ + 'create_profile', + 'update_profile', + 'recommend_profile_change', + 'recommend_trade_plan', + 'recommend_reconciliation_followup', + 'review_recent_trades', + 'explain', + 'explain_position', + 'explain_waiting', + 'explain_blocker', + 'summarize_reconciliation', + ]) { + assertSourceIncludes(`'${action}'`, `chat copilot must support ${action}`); + } + + assertSourceIncludes( + '"action": "create_profile" | "update_profile" | "recommend_profile_change" | "recommend_trade_plan" | "recommend_reconciliation_followup" | "review_recent_trades" | "explain" | "explain_position" | "explain_waiting" | "explain_blocker" | "summarize_reconciliation"', + 'chat prompt contract must document the full action enum', + ); + console.log('[PASS] chat action contract is documented and supported.'); +} + +function testQuickLinkContract() { + assertSourceIncludes( + `"quickLinks": [{ kind: "portfolio" | "plans" | "settings", ... }] (optional safe in-app destinations)`, + 'chat prompt contract must document quickLinks', + ); + assertSourceIncludes( + 'Include safe "nextActions" and "quickLinks" whenever they would help the user move to the right app surface.', + 'chat prompt rules must require safe nextActions/quickLinks guidance', + ); + assertSourceIncludes( + "{ kind: 'plans', label: 'Manage in Plans'", + 'chat copilot must be able to deep-link into Trade Plans', + ); + assertSourceIncludes( + "{ kind: 'settings', label: 'Open Admin Panel', section: 'Admin Panel' }", + 'chat copilot must be able to deep-link into the admin panel', + ); + assertSourceIncludes( + "{ kind: 'portfolio', label: 'Open Portfolio'", + 'chat copilot must be able to deep-link into Portfolio', + ); + console.log('[PASS] quick-link contract is covered.'); +} + +function testRuntimeCopilotFallbacks() { + assertSourceIncludes( + 'this.buildTradePlanRecommendation(message, context)', + 'local fallback must support trade-plan recommendations', + ); + assertSourceIncludes( + 'return this.buildReconciliationFollowup(context);', + 'local fallback must support reconciliation follow-up guidance', + ); + assertSourceIncludes( + 'this.buildRecentTradeReview(context)', + 'local fallback must support recent-trade review guidance', + ); + console.log('[PASS] runtime copilot fallback intents are covered.'); +} + +function main() { + testChatRouteRequiresAuth(); + testChatActionContract(); + testQuickLinkContract(); + testRuntimeCopilotFallbacks(); + console.log('Chat copilot contract checks passed'); +} + +main(); diff --git a/web/src/components/ChatControl.dom.test.tsx b/web/src/components/ChatControl.dom.test.tsx index 6b7b54b..88e8fee 100644 --- a/web/src/components/ChatControl.dom.test.tsx +++ b/web/src/components/ChatControl.dom.test.tsx @@ -2,7 +2,7 @@ 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 { MemoryRouter } from 'react-router-dom'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { ChatControl } from './ChatControl'; import { DEFAULT_BOT_STATE } from '../hooks/useWebSocket'; import type { BotState } from '../hooks/useWebSocket'; @@ -75,9 +75,15 @@ const botStateFixture: BotState = { }; describe('ChatControl DOM flow', () => { + const LocationProbe = () => { + const location = useLocation(); + return
{`${location.pathname}${location.search}`}
; + }; + const renderControl = (onApplyProfile = vi.fn(async () => ({ success: true }))) => render( - + + ); @@ -346,4 +352,40 @@ describe('ChatControl DOM flow', () => { expect(screen.getByRole('button', { name: /Open Portfolio/i })).toBeInTheDocument(); }); }); + + it('navigates through safe quick links into plans and settings routes', async () => { + getPlatformAccessTokenMock.mockResolvedValue('token-6'); + vi.mocked(fetch).mockResolvedValue({ + ok: true, + json: async () => ({ + summary: 'Reconciliation needs follow-up before more execution.', + reasoning: 'Current mismatch counts show the admin panel is the safest next stop.', + action: 'recommend_reconciliation_followup', + quickLinks: [ + { kind: 'settings', label: 'Open Admin Panel', section: 'Admin Panel' }, + { kind: 'plans', label: 'Manage in Plans', mode: 'sell', symbol: 'BTC/USDT', tradeId: 'trade-1' } + ] + }) + } as any); + + const user = userEvent.setup(); + renderControl(); + await user.click(screen.getAllByRole('button')[0]); + await user.type(screen.getByPlaceholderText(/Ask for a profile, holding explanation, or reconciliation help/i), 'What should I do about this reconciliation mismatch?{enter}'); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /Open Admin Panel/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /Open Admin Panel/i })); + await waitFor(() => { + expect(screen.getByTestId('chat-location')).toHaveTextContent('/settings?section=Admin+Panel'); + }); + + await user.click(screen.getAllByRole('button')[0]); + await user.click(screen.getByRole('button', { name: /Manage in Plans/i })); + await waitFor(() => { + expect(screen.getByTestId('chat-location')).toHaveTextContent('/plans?mode=sell&symbol=BTC%2FUSDT&tradeId=trade-1'); + }); + }); });