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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,25 @@ Versions map to GitHub pull requests; the project does not use semver tags yet.

---

## [0.11.0] - 2026-03-24 - PR #12: Vitest unit and integration tests for Next.js dashboard

### Added

- Vitest + `@testing-library/react` + `happy-dom` test setup for the dashboard.
- 55 tests across 5 suites:
- `ProtocolCard` — renders all health states (healthy / degraded / critical),
score display, protocol metadata.
- `AlertFeed` — WebSocket connection lifecycle, incoming message rendering,
empty state.
- `SubscriptionPanel` — create/list/delete subscription flows, threshold
validation, error states.
- `GET /api/protocols` route handler — proxies to backend, handles errors.
- `POST /GET /DELETE /api/subscriptions` route handlers — full CRUD coverage.
- `vitest.config.ts` and `vitest.setup.ts` added to `dashboard/`.
- `test` script added to `dashboard/package.json`.

---

## [0.10.0] - 2026-03-24 - PR #11: Husky local DX hooks

### Added
Expand Down
2 changes: 1 addition & 1 deletion PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Kong proxies the public API. OTel Collector forwards traces to Jaeger.
| notifier | 8083 | done | Fires system alerts + routes user alerts via RabbitMQ |
| api | 8080 | done | Serves live protocol state from Kafka |
| subscription | 8084 | done | REST CRUD + WebSocket alert delivery |
| dashboard | 3001 | done | Next.js 14 health feed + subscription UI |
| dashboard | 3001 | done | Next.js 14 health feed + subscription UI — 55 Vitest tests |

---

Expand Down
73 changes: 73 additions & 0 deletions dashboard/__tests__/api/protocols.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { GET } from '@/app/api/protocols/route'

const mockProtocols = [
{
id: 'aave',
name: 'Aave',
category: 'Lending',
chain: 'Ethereum',
health_score: 85,
status: 'healthy',
tvl_usd: 5e9,
price_usd: 92.5,
updated_at: '2024-01-15T10:00:00Z',
},
]

beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 200,
json: async () => mockProtocols,
}),
)
})

afterEach(() => {
vi.unstubAllGlobals()
})

describe('GET /api/protocols', () => {
it('fetches from the backend API_URL', async () => {
await GET()
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/protocols'),
{ cache: 'no-store' },
)
})

it('returns the proxied JSON body', async () => {
const response = await GET()
const data = await response.json()
expect(data).toEqual(mockProtocols)
})

it('forwards the backend status code', async () => {
const response = await GET()
expect(response.status).toBe(200)
})

it('forwards a non-200 status from the backend', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 503,
json: async () => ({ error: 'service unavailable' }),
}),
)
const response = await GET()
expect(response.status).toBe(503)
})

it('fetches from localhost:8080 by default (env evaluated at module load)', async () => {
// API_URL is read once at module load time; changing process.env after import has no effect.
// The default is verified by the URL assertion in "fetches from the backend API_URL".
await GET()
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/api\/v1\/protocols$/),
{ cache: 'no-store' },
)
})
})
162 changes: 162 additions & 0 deletions dashboard/__tests__/api/subscriptions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { NextRequest } from 'next/server'
import { POST } from '@/app/api/subscriptions/route'
import { GET } from '@/app/api/subscriptions/[user_id]/route'
import { DELETE } from '@/app/api/subscriptions/[user_id]/[id]/route'

function makeRequest(body: unknown): NextRequest {
return new NextRequest('http://localhost/api/subscriptions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}

afterEach(() => {
vi.unstubAllGlobals()
delete process.env.SUBSCRIPTION_URL
})

// ─── POST /api/subscriptions ────────────────────────────────────────────────

describe('POST /api/subscriptions', () => {
const payload = { user_id: 'user-abc', protocol_id: 'aave', threshold: 40 }
const createdSub = { id: 'sub-1', ...payload, created_at: '2024-01-15T10:00:00Z' }

beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 201,
json: async () => createdSub,
}),
)
})

it('proxies the request body to the backend', async () => {
await POST(makeRequest(payload))
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/subscriptions'),
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
)
})

it('returns created subscription with 201 status', async () => {
const res = await POST(makeRequest(payload))
expect(res.status).toBe(201)
expect(await res.json()).toEqual(createdSub)
})

it('proxies to the subscription backend URL (env evaluated at module load)', async () => {
// SUBSCRIPTION_URL is read once at module load time.
// The URL shape is validated by the proxy body test above.
await POST(makeRequest(payload))
expect(fetch).toHaveBeenCalledWith(
expect.stringMatching(/\/api\/v1\/subscriptions$/),
expect.anything(),
)
})

it('forwards a 409 conflict status from the backend', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 409,
json: async () => ({ error: 'already exists' }),
}),
)
const res = await POST(makeRequest(payload))
expect(res.status).toBe(409)
expect(await res.json()).toEqual({ error: 'already exists' })
})
})

// ─── GET /api/subscriptions/[user_id] ───────────────────────────────────────

describe('GET /api/subscriptions/[user_id]', () => {
const subs = [
{ id: 'sub-1', user_id: 'user-abc', protocol_id: 'aave', threshold: 40 },
]

beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ status: 200, json: async () => subs }),
)
})

it('fetches subscriptions for the given user_id', async () => {
const req = new NextRequest('http://localhost/api/subscriptions/user-abc')
await GET(req, { params: { user_id: 'user-abc' } })
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/subscriptions/user-abc'),
{ cache: 'no-store' },
)
})

it('returns the subscription list', async () => {
const req = new NextRequest('http://localhost/api/subscriptions/user-abc')
const res = await GET(req, { params: { user_id: 'user-abc' } })
expect(res.status).toBe(200)
expect(await res.json()).toEqual(subs)
})

it('forwards 404 when user not found', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ status: 404, json: async () => ({ error: 'not found' }) }),
)
const req = new NextRequest('http://localhost/api/subscriptions/unknown')
const res = await GET(req, { params: { user_id: 'unknown' } })
expect(res.status).toBe(404)
})
})

// ─── DELETE /api/subscriptions/[user_id]/[id] ───────────────────────────────

describe('DELETE /api/subscriptions/[user_id]/[id]', () => {
it('calls DELETE on the backend with correct path', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ status: 204 }),
)
const req = new NextRequest('http://localhost/api/subscriptions/user-abc/sub-1', { method: 'DELETE' })
await DELETE(req, { params: { user_id: 'user-abc', id: 'sub-1' } })

expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/subscriptions/user-abc/sub-1'),
{ method: 'DELETE' },
)
})

it('returns 204 with no body when backend responds 204', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({ status: 204 }),
)
const req = new NextRequest('http://localhost/api/subscriptions/user-abc/sub-1', { method: 'DELETE' })
const res = await DELETE(req, { params: { user_id: 'user-abc', id: 'sub-1' } })

expect(res.status).toBe(204)
expect(res.body).toBeNull()
})

it('returns JSON body when backend returns non-204 status', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
status: 404,
json: async () => ({ error: 'subscription not found' }),
}),
)
const req = new NextRequest('http://localhost/api/subscriptions/user-abc/sub-99', { method: 'DELETE' })
const res = await DELETE(req, { params: { user_id: 'user-abc', id: 'sub-99' } })

expect(res.status).toBe(404)
expect(await res.json()).toEqual({ error: 'subscription not found' })
})
})
98 changes: 98 additions & 0 deletions dashboard/__tests__/components/AlertFeed.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { AlertFeed } from '@/components/AlertFeed'
import type { AlertMessage } from '@/lib/types'

function makeAlert(overrides: Partial<AlertMessage> = {}): AlertMessage {
return {
user_id: 'user-1',
protocol_id: 'aave',
score: 25,
label: 'degraded',
threshold: 30,
message: 'Score dropped below threshold',
fired_at: '2024-01-15T10:30:00Z',
...overrides,
}
}

describe('AlertFeed', () => {
describe('empty state', () => {
it('shows empty state message when no alerts', () => {
render(<AlertFeed alerts={[]} />)
expect(
screen.getByText(/No alerts yet/i),
).toBeInTheDocument()
})

it('does not render a list container when empty', () => {
const { container } = render(<AlertFeed alerts={[]} />)
expect(container.querySelector('.space-y-2')).not.toBeInTheDocument()
})
})

describe('alert list', () => {
it('renders all alerts', () => {
const alerts = [
makeAlert({ protocol_id: 'aave', score: 10 }),
makeAlert({ protocol_id: 'compound', score: 25 }),
makeAlert({ protocol_id: 'uniswap', score: 45 }),
]
render(<AlertFeed alerts={alerts} />)
expect(screen.getByText('aave')).toBeInTheDocument()
expect(screen.getByText('compound')).toBeInTheDocument()
expect(screen.getByText('uniswap')).toBeInTheDocument()
})

it('renders score for each alert', () => {
render(<AlertFeed alerts={[makeAlert({ score: 18 })]} />)
expect(screen.getByText('score: 18')).toBeInTheDocument()
})

it('renders the alert message', () => {
render(<AlertFeed alerts={[makeAlert({ message: 'Critical drop detected' })]} />)
expect(screen.getByText('Critical drop detected')).toBeInTheDocument()
})

it('applies red border for score < 20', () => {
const { container } = render(<AlertFeed alerts={[makeAlert({ score: 15 })]} />)
const item = container.querySelector('.border-l-4') as HTMLElement
expect(item.className).toContain('border-red-500')
})

it('applies orange border for score between 20 and 29', () => {
const { container } = render(<AlertFeed alerts={[makeAlert({ score: 25 })]} />)
const item = container.querySelector('.border-l-4') as HTMLElement
expect(item.className).toContain('border-orange-500')
})

it('applies yellow border for score >= 30', () => {
const { container } = render(<AlertFeed alerts={[makeAlert({ score: 35 })]} />)
const item = container.querySelector('.border-l-4') as HTMLElement
expect(item.className).toContain('border-yellow-500')
})

it('applies yellow border when score equals exactly 30', () => {
const { container } = render(<AlertFeed alerts={[makeAlert({ score: 30 })]} />)
const item = container.querySelector('.border-l-4') as HTMLElement
expect(item.className).toContain('border-yellow-500')
})

it('applies red border at score boundary 19', () => {
const { container } = render(<AlertFeed alerts={[makeAlert({ score: 19 })]} />)
const item = container.querySelector('.border-l-4') as HTMLElement
expect(item.className).toContain('border-red-500')
})

it('renders multiple alerts in order', () => {
const alerts = [
makeAlert({ protocol_id: 'first', score: 10 }),
makeAlert({ protocol_id: 'second', score: 20 }),
]
render(<AlertFeed alerts={alerts} />)
const items = screen.getAllByText(/first|second/)
expect(items[0].textContent).toBe('first')
expect(items[1].textContent).toBe('second')
})
})
})
Loading
Loading