Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions docker/postgres-test/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
# PostgreSQL with TimescaleDB and pgvector for CI testing
# Uses the standard TimescaleDB Alpine image (not -ha, which requires Patroni).
# pgvector is built from source since Alpine doesn't have a prebuilt package.
#
# We build with `with_llvm=no` to skip the LLVM bitcode (JIT) step. The base
# image's pg_config reports whichever clang version was used to build the
# upstream postgres package, and that drifts over time (clang15 -> clang19 -> ...).
# Pinning a clang version here breaks every time the base image rolls forward.
# pgvector still works fine without JIT bitcode — the .so is the only thing we
# actually need for tests.

FROM timescale/timescaledb:latest-pg15

USER root
RUN apk add --no-cache --virtual .build-deps \
build-base \
git \
clang15 \
llvm15-dev \
&& cd /tmp \
&& git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git \
&& cd pgvector \
&& make \
&& make install \
&& make OPTFLAGS="" with_llvm=no \
&& make OPTFLAGS="" with_llvm=no install \
&& cd / \
&& rm -rf /tmp/pgvector \
&& apk del .build-deps
Expand Down
18 changes: 16 additions & 2 deletions internal/orchestrator/orchestrator_integration_test.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
//go:build integration

// This test requires the 'integration' build tag to run.
// It is excluded from CI due to flaky race conditions in the split_vote_no_consensus subtest.
// Run locally with: go test -v -tags=integration ./internal/orchestrator/...
// The split_vote_no_consensus subtest is skipped under CI (CI=true) due to a
// flaky race in NATS publish ordering on shared CI runners; run locally with
// the integration tag to exercise the full suite:
//
// go test -v -tags=integration ./internal/orchestrator/...
package orchestrator_test

import (
"context"
"encoding/json"
"os"
"testing"
"time"

Expand Down Expand Up @@ -243,6 +247,16 @@ func TestIntegration_MultiAgentCoordination(t *testing.T) {

// Test Case 4: Split vote - no consensus
t.Run("split_vote_no_consensus", func(t *testing.T) {
// Known flake under CI: NATS publish ordering between the two agent
// groups occasionally lets the orchestrator finalize a decision before
// all four signals arrive in the same round, producing an
// "Insufficient responses for consensus" error instead of the expected
// HOLD. The race is environmental (GH Actions runner scheduling), not
// a real bug, so the subtest is skipped in CI but kept for local runs.
if os.Getenv("CI") != "" {
t.Skip("skipping flaky split_vote_no_consensus under CI; run locally with -tags=integration")
}

// Drain any leftover decisions from previous subtests to avoid a stale
// decision being picked up by this subtest's select.
for len(decisionChan) > 0 {
Expand Down
28 changes: 28 additions & 0 deletions web/dashboard/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# CryptoFunk Dashboard Environment Variables
#
# Copy this file to .env.local and fill in your values.
# .env.local is gitignored and will not be committed.

# API server base URL
NEXT_PUBLIC_API_URL=http://localhost:8080

# WebSocket URL for real-time updates
NEXT_PUBLIC_WS_URL=ws://localhost:8080

# API key for authenticated endpoints (sent as X-API-Key header).
#
# WARNING: NEXT_PUBLIC_* env vars are inlined into the client JavaScript bundle
# and visible to every visitor. Do NOT set this to a privileged production key.
# Use it only for local development against a dev API. For production, proxy
# requests through a Next.js route handler that injects the key server-side.
#
# This value is the PLAINTEXT key. The server stores its SHA-256 in the
# `api_keys` table; generate that hash with:
# echo -n "your-key" | shasum -a 256
NEXT_PUBLIC_API_KEY=

# Set to "true" to serve generated mock candlestick data when the
# /market/candlestick endpoint is unavailable (e.g. local dev before the
# backend ships it). NEVER set this in production — it will mask real
# API failures with fabricated price data.
NEXT_PUBLIC_USE_MOCK_CANDLES=
47 changes: 47 additions & 0 deletions web/dashboard/__tests__/DashboardContent.error.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'

const ok = <T,>(data: T) => ({
data: { success: true as const, data, timestamp: '' },
isLoading: false,
isError: false,
error: null,
})
const err = (message: string) => ({
data: undefined,
isLoading: false,
isError: true,
error: new Error(message),
})

vi.mock('@/hooks/useTradeData', () => ({
useDashboard: () => err('HTTP 401 unauthorized — check NEXT_PUBLIC_API_KEY'),
useDashboardPnl: () => ok({ daily: 0, total: 0, equity: [] }),
useTrades: () => ok([]),
useUnifiedPortfolio: () => ({ data: undefined }),
useSystemStatus: () => err('down'),
}))

vi.mock('@/hooks/useAgents', () => ({
useAgents: () => ok([]),
}))

import DashboardContent from '@/components/dashboard/DashboardContent'

function wrap(children: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}

describe('DashboardContent (error path)', () => {
it('renders an error banner and falls back to unknown status', () => {
render(wrap(<DashboardContent />))

const alert = screen.getByRole('alert')
expect(alert).toHaveTextContent(/dashboard:.*401/)
expect(alert).toHaveTextContent(/status:.*down/)
expect(screen.getByText(/unknown/i)).toBeInTheDocument()
})
})
54 changes: 54 additions & 0 deletions web/dashboard/__tests__/DashboardContent.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import type { ReactNode } from 'react'

const ok = <T,>(data: T) => ({
data: { success: true as const, data, timestamp: '' },
isLoading: false,
isError: false,
error: null,
})

vi.mock('@/hooks/useTradeData', () => ({
useDashboard: () =>
ok({
totalPnl: 0,
winRate: 0,
activePositions: 0,
totalTrades: 0,
equity: 0,
availableBalance: 0,
marginUsed: 0,
}),
useDashboardPnl: () => ok({ daily: 0, total: 0, equity: [] }),
useTrades: () => ok([]),
useUnifiedPortfolio: () => ({ data: undefined }),
useSystemStatus: () => ok({ status: 'healthy', services: {} }),
}))

vi.mock('@/hooks/useAgents', () => ({
useAgents: () => ok([]),
}))

import DashboardContent from '@/components/dashboard/DashboardContent'

function wrap(children: ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}

describe('DashboardContent (happy path)', () => {
it('mounts and renders core sections', () => {
render(wrap(<DashboardContent />))

expect(screen.getByText('Total P&L')).toBeInTheDocument()
expect(screen.getByText('Win Rate')).toBeInTheDocument()
expect(screen.getByText('Active Positions')).toBeInTheDocument()
expect(screen.getByText('Equity Curve')).toBeInTheDocument()
expect(screen.getByText('Recent Trades')).toBeInTheDocument()
expect(screen.getByText(/healthy/i)).toBeInTheDocument()

expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
})
134 changes: 134 additions & 0 deletions web/dashboard/__tests__/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

// These tests verify the X-API-Key auth header plumbing introduced in
// PR #201. A regression here silently breaks the entire dashboard when
// the backend has auth.enabled=true, so the contract is worth pinning.

async function loadApi() {
vi.resetModules()
return await import('@/lib/api')
}

function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
...init,
})
}

beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_API_URL', 'http://test.local')
})

afterEach(() => {
vi.unstubAllEnvs()
vi.restoreAllMocks()
})

describe('apiClient X-API-Key header', () => {
it('attaches X-API-Key when NEXT_PUBLIC_API_KEY is set', async () => {
vi.stubEnv('NEXT_PUBLIC_API_KEY', 'test-key-123')
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockResolvedValue(jsonResponse({ success: true, data: { status: 'ok' } }))

const { apiClient } = await loadApi()
await apiClient.getHealth()

expect(fetchSpy).toHaveBeenCalledTimes(1)
const init = fetchSpy.mock.calls[0]![1] as RequestInit
const headers = init.headers as Record<string, string>
expect(headers['X-API-Key']).toBe('test-key-123')
})

it('omits X-API-Key when NEXT_PUBLIC_API_KEY is unset', async () => {
vi.stubEnv('NEXT_PUBLIC_API_KEY', '')
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockResolvedValue(jsonResponse({ success: true, data: { status: 'ok' } }))

const { apiClient } = await loadApi()
await apiClient.getHealth()

const init = fetchSpy.mock.calls[0]![1] as RequestInit
const headers = init.headers as Record<string, string>
expect(headers['X-API-Key']).toBeUndefined()
})

it('caller-supplied headers cannot drop X-API-Key', async () => {
vi.stubEnv('NEXT_PUBLIC_API_KEY', 'guarded')
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockResolvedValue(jsonResponse({ success: true, data: {} }))

const { apiClient } = await loadApi()
// createOrder spreads `options` (with body) and merges headers; verify
// the auth header still ends up on the request.
await apiClient.createOrder({ symbol: 'BTC/USDT' })

const init = fetchSpy.mock.calls[0]![1] as RequestInit
const headers = init.headers as Record<string, string>
expect(headers['X-API-Key']).toBe('guarded')
})

it('returns success:false with a friendly message on 401', async () => {
vi.stubEnv('NEXT_PUBLIC_API_KEY', 'wrong')
vi.spyOn(global, 'fetch').mockResolvedValue(
new Response('unauthorized', { status: 401 })
)

const { apiClient } = await loadApi()
const res = await apiClient.getHealth()

expect(res.success).toBe(false)
expect(res.error).toMatch(/NEXT_PUBLIC_API_KEY/)
})
})

describe('getMarketCandlestick URL encoding', () => {
it('percent-encodes symbols containing slashes', async () => {
vi.stubEnv('NEXT_PUBLIC_API_KEY', '')
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockResolvedValue(jsonResponse({ success: true, data: [] }))

const { apiClient } = await loadApi()
await apiClient.getMarketCandlestick('BTC/USDT', '5m')

const url = fetchSpy.mock.calls[0]![0] as string
expect(url).toContain('/market/candlestick/BTC%2FUSDT')
expect(url).toContain('timeRange=5m')
})
})

describe('decision analytics fetchers', () => {
it('fetchDecisionAnalytics surfaces 500 as success:false instead of returning the error body', async () => {
vi.stubEnv('NEXT_PUBLIC_API_KEY', 'k')
vi.spyOn(global, 'fetch').mockResolvedValue(
new Response('boom', { status: 500 })
)

const { fetchDecisionAnalytics } = await loadApi()
const res = await fetchDecisionAnalytics('30d')

expect(res.success).toBe(false)
expect(res.error).toMatch(/500/)
})

it('triggerOutcomeResolution sends X-API-Key on POST', async () => {
vi.stubEnv('NEXT_PUBLIC_API_KEY', 'k')
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockResolvedValue(
jsonResponse({ success: true, data: { polymarket_resolved: 0, binance_resolved: 0 } })
)

const { triggerOutcomeResolution } = await loadApi()
await triggerOutcomeResolution()

const init = fetchSpy.mock.calls[0]![1] as RequestInit
expect(init.method).toBe('POST')
expect((init.headers as Record<string, string>)['X-API-Key']).toBe('k')
})
})
23 changes: 23 additions & 0 deletions web/dashboard/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,29 @@

@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.75rem;
}

.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
Expand Down
2 changes: 1 addition & 1 deletion web/dashboard/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export default function RootLayout({
children: React.ReactNode
}) {
return (
<html lang="en" className="dark">
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
Expand Down
Loading
Loading