Fix download button and API prefix issues #53
@@ -80,3 +80,31 @@ jobs:
|
||||
# Manually connect to networks after container is created
|
||||
docker network connect nas-dashboard_internal nas-dashboard-dev || true
|
||||
docker network connect gitea_gitea nas-dashboard-dev || true
|
||||
- name: Wait for container to be healthy
|
||||
run: |
|
||||
echo "Waiting for container to be healthy..."
|
||||
for i in {1..30}; do
|
||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' nas-dashboard-dev 2>/dev/null || echo "starting")
|
||||
if [ "$STATUS" = "healthy" ]; then
|
||||
echo "Container is healthy"
|
||||
break
|
||||
fi
|
||||
echo "Waiting... ($i/30) Status: $STATUS"
|
||||
sleep 2
|
||||
done
|
||||
# Final check
|
||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' nas-dashboard-dev 2>/dev/null || echo "unknown")
|
||||
if [ "$STATUS" != "healthy" ]; then
|
||||
echo "Container failed to become healthy: $STATUS"
|
||||
docker logs nas-dashboard-dev --tail 50
|
||||
exit 1
|
||||
fi
|
||||
- name: Run smoke tests
|
||||
run: |
|
||||
cd /volume1/repos/nas-tools
|
||||
bash scripts/smoke-test-dashboard.sh dev
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Smoke tests failed! Check logs above."
|
||||
docker logs nas-dashboard-dev --tail 100
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -72,6 +72,13 @@ jobs:
|
||||
--junit-xml=test-results.xml \
|
||||
--cov-fail-under=49
|
||||
|
||||
# Explicitly check exit code
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Backend tests failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Backend tests passed"
|
||||
|
||||
- name: Upload coverage report
|
||||
if: always()
|
||||
run: |
|
||||
@@ -145,6 +152,13 @@ jobs:
|
||||
cd dashboard/frontend
|
||||
npm run test:coverage -- --reporter=verbose --run --pool=forks --poolOptions.forks.maxForks=2
|
||||
|
||||
# Explicitly check exit code
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Frontend tests failed!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Frontend tests passed"
|
||||
|
||||
test-summary:
|
||||
name: Test Summary
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
# Dashboard v1.3 — Runner DNS fix applied
|
||||
# Dashboard v1.4 — Conversation tracker enabled
|
||||
|
||||
GITEA_URL = os.environ.get("GITEA_URL", "http://gitea:3000")
|
||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
||||
|
||||
@@ -299,6 +299,7 @@ app.include_router(
|
||||
)
|
||||
app.include_router(
|
||||
conversation_tracker.router,
|
||||
prefix="/api/conversations",
|
||||
dependencies=[Depends(_inject_user), Depends(require_page("conversations"))],
|
||||
)
|
||||
app.include_router(
|
||||
|
||||
@@ -3,7 +3,7 @@ import aiosqlite
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
|
||||
router = APIRouter(prefix="/api/conversations", tags=["conversations"])
|
||||
router = APIRouter(tags=["conversations"])
|
||||
|
||||
CONVERSATION_TRACKER_DB = os.environ.get("CONVERSATION_TRACKER_DB", "/app/data/claude-code-tracker/conversations.db")
|
||||
|
||||
|
||||
@@ -287,3 +287,64 @@ def mock_request():
|
||||
request.client.host = "192.168.31.100"
|
||||
request.headers = {}
|
||||
return request
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_conversation_db(tmp_path, monkeypatch):
|
||||
"""Mock conversation tracker database"""
|
||||
import sqlite3
|
||||
|
||||
db_path = tmp_path / "conversations.db"
|
||||
|
||||
# Create minimal SQLite schema
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("""
|
||||
CREATE TABLE conversations (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
project_path TEXT,
|
||||
started_at DATETIME,
|
||||
message_count INTEGER,
|
||||
total_input_tokens INTEGER DEFAULT 0,
|
||||
total_output_tokens INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
message_uuid TEXT UNIQUE NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content_text TEXT,
|
||||
timestamp DATETIME NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE tool_calls (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_uuid TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
timestamp DATETIME NOT NULL
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE TABLE daily_summaries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT UNIQUE NOT NULL,
|
||||
summary_content TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
|
||||
# Insert test data
|
||||
conn.execute("""
|
||||
INSERT INTO conversations VALUES
|
||||
('test-session-id', '/test/project', '2026-04-19 10:00:00', 10, 1000, 500)
|
||||
""")
|
||||
conn.execute("""
|
||||
INSERT INTO messages VALUES
|
||||
(1, 'test-session-id', 'msg-1', 'user', 'test message', '2026-04-19 10:00:00')
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
monkeypatch.setenv("CONVERSATION_TRACKER_DB", str(db_path))
|
||||
return db_path
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestConversationTrackerAPI:
|
||||
"""Test conversation tracker endpoints"""
|
||||
|
||||
def test_list_dates_requires_auth(self, test_app):
|
||||
"""Verify dates endpoint requires authentication"""
|
||||
response = test_app.get("/api/conversations/dates")
|
||||
assert response.status_code == 401
|
||||
assert "Not authenticated" in response.json()["detail"]
|
||||
|
||||
def test_list_dates_with_auth(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Verify dates endpoint returns data with auth"""
|
||||
response = test_app.get("/api/conversations/dates", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
def test_get_summary_not_found(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Test daily summary endpoint returns 404 when no summary exists"""
|
||||
response = test_app.get("/api/conversations/summary/2026-04-19", headers=admin_headers)
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_list_conversations(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Test conversation list endpoint"""
|
||||
response = test_app.get("/api/conversations/list?date=2026-04-19", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert isinstance(data, list)
|
||||
if len(data) > 0:
|
||||
assert "session_id" in data[0]
|
||||
assert "project_path" in data[0]
|
||||
|
||||
def test_list_conversations_requires_auth(self, test_app):
|
||||
"""Test conversation list requires authentication"""
|
||||
response = test_app.get("/api/conversations/list?date=2026-04-19")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_get_conversation_detail(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Test conversation detail endpoint"""
|
||||
response = test_app.get("/api/conversations/test-session-id", headers=admin_headers)
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
def test_get_conversation_messages(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Test conversation messages endpoint"""
|
||||
response = test_app.get("/api/conversations/test-session-id/messages", headers=admin_headers)
|
||||
assert response.status_code in [200, 404]
|
||||
|
||||
def test_search_conversations(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Test search endpoint"""
|
||||
response = test_app.get("/api/conversations/search?q=test", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
def test_search_requires_query(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Test search requires query parameter"""
|
||||
response = test_app.get("/api/conversations/search", headers=admin_headers)
|
||||
# Should handle missing query gracefully
|
||||
assert response.status_code in [200, 400, 422]
|
||||
|
||||
def test_get_stats(self, test_app, admin_headers, mock_conversation_db):
|
||||
"""Test stats endpoint"""
|
||||
response = test_app.get("/api/conversations/stats", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "top_projects" in data
|
||||
assert "top_tools" in data
|
||||
assert isinstance(data["top_projects"], list)
|
||||
assert isinstance(data["top_tools"], list)
|
||||
|
||||
def test_trigger_summary_requires_auth(self, test_app):
|
||||
"""Test trigger endpoint requires authentication"""
|
||||
response = test_app.post("/api/conversations/trigger")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_api_prefix_correct(self, test_app, admin_headers):
|
||||
"""Test that API paths don't have double /api/ prefix"""
|
||||
# This should be 404, not a valid endpoint
|
||||
response = test_app.get("/api/api/conversations/dates", headers=admin_headers)
|
||||
assert response.status_code == 404
|
||||
@@ -217,14 +217,16 @@ export async function download(path, filename) {
|
||||
|
||||
const { token: downloadToken } = await tokenResponse.json();
|
||||
|
||||
// Use the token to trigger browser download (no memory loading)
|
||||
const downloadUrl = `${BASE}/files/download?token=${downloadToken}`;
|
||||
const a = document.createElement("a");
|
||||
a.href = downloadUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Use hidden iframe for download - avoids page navigation and popup blockers
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.style.display = 'none';
|
||||
iframe.src = `${BASE}/files/download?token=${downloadToken}`;
|
||||
document.body.appendChild(iframe);
|
||||
|
||||
// Clean up iframe after download starts
|
||||
setTimeout(() => {
|
||||
document.body.removeChild(iframe);
|
||||
}, 5000);
|
||||
} catch (e) {
|
||||
console.error("Download error:", e);
|
||||
throw e;
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
dates = await get("/api/conversations/dates");
|
||||
dates = await get("/conversations/dates");
|
||||
if (dates.length) selectDate(dates[0]);
|
||||
loadStats();
|
||||
} catch (e) {
|
||||
@@ -32,8 +32,8 @@
|
||||
loading = true;
|
||||
error = "";
|
||||
try {
|
||||
summary = await get(`/api/conversations/summary/${d}`);
|
||||
conversations = await get(`/api/conversations/list?date=${d}`);
|
||||
summary = await get(`/conversations/summary/${d}`);
|
||||
conversations = await get(`/conversations/list?date=${d}`);
|
||||
} catch (e) {
|
||||
summary = null;
|
||||
if (e.status !== 404) error = e.body?.detail || "Failed to load summary";
|
||||
@@ -46,8 +46,8 @@
|
||||
view = "detail";
|
||||
loading = true;
|
||||
try {
|
||||
selectedConversation = await get(`/api/conversations/${sessionId}`);
|
||||
messages = await get(`/api/conversations/${sessionId}/messages`);
|
||||
selectedConversation = await get(`/conversations/${sessionId}`);
|
||||
messages = await get(`/conversations/${sessionId}/messages`);
|
||||
} catch (e) {
|
||||
error = "Failed to load conversation";
|
||||
} finally {
|
||||
@@ -60,7 +60,7 @@
|
||||
view = "search";
|
||||
loading = true;
|
||||
try {
|
||||
searchResults = await get(`/api/conversations/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
searchResults = await get(`/conversations/search?q=${encodeURIComponent(searchQuery)}`);
|
||||
} catch (e) {
|
||||
error = "Search failed";
|
||||
} finally {
|
||||
@@ -70,7 +70,7 @@
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
stats = await get("/api/conversations/stats");
|
||||
stats = await get("/conversations/stats");
|
||||
} catch (e) {
|
||||
console.error("Failed to load stats", e);
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
async function trigger() {
|
||||
triggering = true;
|
||||
try {
|
||||
await post("/api/conversations/trigger");
|
||||
await post("/conversations/trigger");
|
||||
setTimeout(() => selectDate(selectedDate), 5000);
|
||||
} catch (e) {
|
||||
error = "Trigger failed";
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
let currentPath = $state("");
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let downloading = $state(false);
|
||||
let error = $state("");
|
||||
let fileInput;
|
||||
|
||||
@@ -33,15 +32,12 @@
|
||||
|
||||
async function handleDownload(name) {
|
||||
const path = currentPath ? `${currentPath}/${name}` : name;
|
||||
downloading = true;
|
||||
error = "";
|
||||
try {
|
||||
await download(path, name);
|
||||
} catch (e) {
|
||||
error = `Failed to download "${name}": ${e.message}`;
|
||||
console.error("Download failed:", e);
|
||||
} finally {
|
||||
downloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,8 +161,8 @@
|
||||
<span class="text-xs text-surface-400 text-right">{e.is_dir ? "—" : formatSize(e.size)}</span>
|
||||
<div class="flex items-center justify-end gap-1 opacity-100 md:opacity-0 md:group-hover:opacity-100 transition-opacity">
|
||||
{#if !e.is_dir}
|
||||
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors disabled:opacity-50" disabled={downloading}>
|
||||
{downloading ? "..." : "DL"}
|
||||
<button onclick={() => handleDownload(e.name)} class="text-[11px] text-primary-500 hover:text-primary-700 font-medium transition-colors">
|
||||
DL
|
||||
</button>
|
||||
{/if}
|
||||
<button class="text-[11px] text-rose-400 hover:text-rose-600 font-medium transition-colors" onclick={() => remove(e.name)}>Del</button>
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { get, post } from '../../src/lib/api.js';
|
||||
|
||||
describe('Conversation Tracker API Paths', () => {
|
||||
beforeEach(() => {
|
||||
// Mock fetch globally
|
||||
global.fetch = vi.fn();
|
||||
// Mock localStorage
|
||||
global.localStorage = {
|
||||
getItem: vi.fn(() => 'mock-token'),
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should call /api/conversations/dates without double prefix', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ['2026-04-19'],
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await get('/conversations/dates');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/conversations/dates'),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/api/'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should call /api/conversations/summary/:date correctly', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ summary: 'test' }),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await get('/conversations/summary/2026-04-19');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/conversations/summary/2026-04-19'),
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(global.fetch).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/api/'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should call /api/conversations/list correctly', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await get('/conversations/list?date=2026-04-19');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/conversations/list?date=2026-04-19'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should call search endpoint correctly', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => [],
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await get('/conversations/search?q=test');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/conversations/search?q=test'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should call stats endpoint correctly', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ top_projects: [], top_tools: [] }),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await get('/conversations/stats');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/conversations/stats'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should call conversation detail endpoint correctly', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ session_id: 'test' }),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await get('/conversations/test-session-id');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/conversations/test-session-id'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('should call trigger endpoint correctly', async () => {
|
||||
global.fetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ status: 'ok' }),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await post('/conversations/trigger');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/conversations/trigger'),
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
# Testing Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers the multi-layered testing approach for the NAS Dashboard to ensure features work correctly before and after deployment.
|
||||
|
||||
## Testing Layers
|
||||
|
||||
```
|
||||
/\
|
||||
/E2E\ <- Full user workflows (optional)
|
||||
/------\
|
||||
/Smoke \ <- Post-deployment verification
|
||||
/----------\
|
||||
/Integration\ <- API and component integration
|
||||
/--------------\
|
||||
/ Unit Tests \ <- Fast, isolated tests
|
||||
------------------
|
||||
```
|
||||
|
||||
## Running Tests Locally
|
||||
|
||||
### Backend Tests
|
||||
|
||||
```bash
|
||||
cd dashboard/backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
pytest tests/ --cov=. --cov-report=term
|
||||
```
|
||||
|
||||
**Run specific test file:**
|
||||
```bash
|
||||
pytest tests/integration/test_conversation_tracker.py -v
|
||||
```
|
||||
|
||||
**Run with coverage:**
|
||||
```bash
|
||||
pytest tests/ --cov=. --cov-report=html
|
||||
open htmlcov/index.html
|
||||
```
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
```bash
|
||||
cd dashboard/frontend
|
||||
npm install
|
||||
npm run test
|
||||
```
|
||||
|
||||
**Run with coverage:**
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
**Run in watch mode:**
|
||||
```bash
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
### Smoke Tests
|
||||
|
||||
Test dev deployment:
|
||||
```bash
|
||||
./scripts/smoke-test-dashboard.sh dev
|
||||
```
|
||||
|
||||
Test production deployment:
|
||||
```bash
|
||||
./scripts/smoke-test-dashboard.sh prod
|
||||
```
|
||||
|
||||
## Pre-Deployment Checklist
|
||||
|
||||
Before pushing code to dev branch:
|
||||
|
||||
- [ ] Run backend tests locally: `cd dashboard/backend && pytest tests/`
|
||||
- [ ] Run frontend tests locally: `cd dashboard/frontend && npm run test`
|
||||
- [ ] Verify API paths don't have double `/api/` prefixes
|
||||
- [ ] Check router registration in `main.py` has correct prefix
|
||||
- [ ] Test feature manually in local environment if possible
|
||||
- [ ] Review code changes for obvious issues
|
||||
|
||||
## Post-Deployment Verification
|
||||
|
||||
### After Deployment to Dev
|
||||
|
||||
1. Wait for CI/CD to complete (check Gitea Actions)
|
||||
2. Run smoke tests:
|
||||
```bash
|
||||
./scripts/smoke-test-dashboard.sh dev
|
||||
```
|
||||
3. Manually test the feature in browser at `http://nas.jimmygan.com:4001`
|
||||
4. Check container logs for errors:
|
||||
```bash
|
||||
ssh nas "docker logs nas-dashboard-dev --tail 50"
|
||||
```
|
||||
|
||||
### After Deployment to Production
|
||||
|
||||
1. Run smoke tests:
|
||||
```bash
|
||||
./scripts/smoke-test-dashboard.sh prod
|
||||
```
|
||||
2. Verify health monitoring is active:
|
||||
```bash
|
||||
ssh nas "tail -20 /volume1/repos/nas-tools/logs/health-monitor.log"
|
||||
```
|
||||
3. Test critical user workflows manually
|
||||
|
||||
## Adding Tests for New Features
|
||||
|
||||
### 1. Backend API Tests
|
||||
|
||||
Create integration test in `dashboard/backend/tests/integration/`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
|
||||
class TestMyNewFeature:
|
||||
def test_endpoint_requires_auth(self, test_app):
|
||||
response = test_app.get("/api/my-feature/endpoint")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_endpoint_with_auth(self, test_app, admin_headers):
|
||||
response = test_app.get("/api/my-feature/endpoint", headers=admin_headers)
|
||||
assert response.status_code == 200
|
||||
```
|
||||
|
||||
### 2. Frontend API Path Tests
|
||||
|
||||
Create test in `dashboard/frontend/tests/integration/`:
|
||||
|
||||
```javascript
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { get } from '../../src/lib/api.js';
|
||||
|
||||
describe('My Feature API Paths', () => {
|
||||
it('should call correct API path', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
await get('/my-feature/endpoint');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/my-feature/endpoint'),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Update Smoke Tests
|
||||
|
||||
Add checks to `scripts/smoke-test-dashboard.sh`:
|
||||
|
||||
```bash
|
||||
# Test new feature endpoint
|
||||
echo "Testing my new feature..."
|
||||
RESPONSE=$(curl -s "$BASE_URL/api/my-feature/health")
|
||||
if echo "$RESPONSE" | grep -q "ok"; then
|
||||
echo "✅ My feature is working"
|
||||
else
|
||||
echo "❌ My feature failed"
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
## CI/CD Pipeline
|
||||
|
||||
Tests run automatically on every push via `.gitea/workflows/test.yml`:
|
||||
|
||||
### Backend Tests
|
||||
- Runs pytest with 49% minimum coverage
|
||||
- Timeout: 30 seconds per test
|
||||
- Generates JUnit XML and coverage reports
|
||||
|
||||
### Frontend Tests
|
||||
- Runs vitest with coverage
|
||||
- Tests all components and API integrations
|
||||
|
||||
### Deployment Tests
|
||||
- Smoke tests run after dev deployment
|
||||
- Health checks verify container is healthy
|
||||
- Logs checked for critical errors
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Check Monitoring
|
||||
|
||||
Runs every 5 minutes via cron on NAS:
|
||||
|
||||
```bash
|
||||
*/5 * * * * /volume1/repos/nas-tools/scripts/monitor-dashboard-health.sh >> /volume1/repos/nas-tools/logs/health-monitor.log 2>&1
|
||||
```
|
||||
|
||||
**What it checks:**
|
||||
- Production dashboard `/api/health` endpoint
|
||||
- Dev dashboard `/api/health` endpoint (non-critical)
|
||||
- Sends Telegram alert on production failure
|
||||
- Sends recovery notification when fixed
|
||||
|
||||
**View logs:**
|
||||
```bash
|
||||
ssh nas "tail -f /volume1/repos/nas-tools/logs/health-monitor.log"
|
||||
```
|
||||
|
||||
### Manual Health Check
|
||||
|
||||
```bash
|
||||
# Check production
|
||||
curl http://nas.jimmygan.com:4000/api/health
|
||||
|
||||
# Check dev
|
||||
curl http://nas.jimmygan.com:4001/api/health
|
||||
|
||||
# Check container health
|
||||
ssh nas "docker ps | grep nas-dashboard"
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests Failing Locally
|
||||
|
||||
**Backend:**
|
||||
```bash
|
||||
# Check Python version (should be 3.12+)
|
||||
python --version
|
||||
|
||||
# Reinstall dependencies
|
||||
cd dashboard/backend
|
||||
rm -rf venv
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements-dev.txt
|
||||
```
|
||||
|
||||
**Frontend:**
|
||||
```bash
|
||||
# Clear node_modules and reinstall
|
||||
cd dashboard/frontend
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### Smoke Tests Failing
|
||||
|
||||
1. Check if container is running:
|
||||
```bash
|
||||
ssh nas "docker ps | grep nas-dashboard"
|
||||
```
|
||||
|
||||
2. Check container logs:
|
||||
```bash
|
||||
ssh nas "docker logs nas-dashboard-dev --tail 100"
|
||||
```
|
||||
|
||||
3. Verify network connectivity:
|
||||
```bash
|
||||
curl -v http://nas.jimmygan.com:4001/api/health
|
||||
```
|
||||
|
||||
4. Check if database is accessible (for conversation tracker):
|
||||
```bash
|
||||
ssh nas "ls -la /volume1/docker/claude-code-tracker/data/conversations.db"
|
||||
```
|
||||
|
||||
### CI/CD Tests Failing
|
||||
|
||||
1. Check Gitea Actions logs in web UI
|
||||
2. Look for specific test failures in output
|
||||
3. Run the same tests locally to reproduce
|
||||
4. Fix issues and push again
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Writing Tests
|
||||
|
||||
1. **Test behavior, not implementation** - Focus on what the API returns, not how it works internally
|
||||
2. **Use descriptive test names** - `test_endpoint_requires_auth` is better than `test_1`
|
||||
3. **One assertion per test** - Makes failures easier to diagnose
|
||||
4. **Mock external dependencies** - Don't rely on real databases, APIs, or services
|
||||
5. **Test error cases** - Not just the happy path
|
||||
|
||||
### Test Coverage
|
||||
|
||||
- **Aim for 50%+ coverage** - Current backend is at 49%
|
||||
- **Focus on critical paths** - Auth, data access, user-facing features
|
||||
- **Don't chase 100%** - Some code (like error handlers) is hard to test
|
||||
|
||||
### Deployment Safety
|
||||
|
||||
1. **Always test in dev first** - Never push directly to production
|
||||
2. **Run smoke tests after deployment** - Automated verification catches issues
|
||||
3. **Monitor logs after deployment** - Watch for errors in first 5-10 minutes
|
||||
4. **Have rollback plan** - Know how to revert to previous version
|
||||
|
||||
## Common Issues and Solutions
|
||||
|
||||
### Issue: Double `/api/` prefix in API calls
|
||||
|
||||
**Symptom:** Frontend gets 404 errors, logs show `/api/api/endpoint`
|
||||
|
||||
**Solution:**
|
||||
- Check router definition: should NOT have `prefix="/api/..."` in `APIRouter()`
|
||||
- Check router registration: SHOULD have `prefix="/api/..."` in `app.include_router()`
|
||||
- Frontend should call `get("/endpoint")` not `get("/api/endpoint")`
|
||||
|
||||
**Test:** Run `dashboard/frontend/tests/integration/conversations.test.js`
|
||||
|
||||
### Issue: Tests pass locally but fail in CI
|
||||
|
||||
**Possible causes:**
|
||||
- Different Python/Node versions
|
||||
- Missing environment variables
|
||||
- Timing issues (increase timeouts)
|
||||
- File path differences
|
||||
|
||||
**Solution:** Check CI logs for specific error, replicate environment locally
|
||||
|
||||
### Issue: Smoke tests timeout
|
||||
|
||||
**Possible causes:**
|
||||
- Container not fully started
|
||||
- Network issues
|
||||
- Service crashed
|
||||
|
||||
**Solution:**
|
||||
```bash
|
||||
# Check container status
|
||||
ssh nas "docker ps -a | grep nas-dashboard"
|
||||
|
||||
# Check container logs
|
||||
ssh nas "docker logs nas-dashboard-dev --tail 50"
|
||||
|
||||
# Restart container
|
||||
ssh nas "cd /volume1/docker/nas-dashboard && docker compose -f docker-compose.dev.yml up -d"
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **Backend Test Fixtures:** `dashboard/backend/tests/conftest.py`
|
||||
- **Frontend Test Setup:** `dashboard/frontend/tests/setup.js`
|
||||
- **CI/CD Workflows:** `.gitea/workflows/`
|
||||
- **Smoke Tests:** `scripts/smoke-test-dashboard.sh`
|
||||
- **Health Monitor:** `scripts/monitor-dashboard-health.sh`
|
||||
|
||||
## Getting Help
|
||||
|
||||
If tests are failing and you're stuck:
|
||||
|
||||
1. Check this documentation first
|
||||
2. Look at existing tests for examples
|
||||
3. Check CI/CD logs for specific errors
|
||||
4. Review recent code changes that might have broken tests
|
||||
5. Ask for help with specific error messages and context
|
||||
Executable
+48
@@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Monitor dashboard health and send alerts
|
||||
# Run via cron: */5 * * * * /volume1/repos/nas-tools/scripts/monitor-dashboard-health.sh
|
||||
|
||||
PROD_URL="http://nas.jimmygan.com:4000"
|
||||
DEV_URL="http://nas.jimmygan.com:4001"
|
||||
ALERT_FILE="/tmp/dashboard-health-alert-sent"
|
||||
LOG_DIR="/volume1/repos/nas-tools/logs"
|
||||
|
||||
# Create log directory if it doesn't exist
|
||||
mkdir -p "$LOG_DIR"
|
||||
|
||||
check_endpoint() {
|
||||
local url=$1
|
||||
local name=$2
|
||||
|
||||
response=$(curl -s -o /dev/null -w "%{http_code}" "$url/api/health" --max-time 5)
|
||||
|
||||
if [ "$response" != "200" ]; then
|
||||
echo "$(date): ❌ $name health check failed (HTTP $response)"
|
||||
return 1
|
||||
fi
|
||||
echo "$(date): ✅ $name health check passed"
|
||||
return 0
|
||||
}
|
||||
|
||||
# Check production
|
||||
if ! check_endpoint "$PROD_URL" "Production"; then
|
||||
if [ ! -f "$ALERT_FILE" ]; then
|
||||
# Send alert only once until issue is resolved
|
||||
/volume1/repos/nas-tools/notify.sh "⚠️ Dashboard Production Health Check Failed - HTTP endpoint not responding"
|
||||
touch "$ALERT_FILE"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check dev (non-critical, just log)
|
||||
if ! check_endpoint "$DEV_URL" "Dev"; then
|
||||
echo "$(date): ⚠️ Dev dashboard health check failed (non-critical)"
|
||||
fi
|
||||
|
||||
# Clear alert file if checks pass
|
||||
if [ -f "$ALERT_FILE" ]; then
|
||||
rm -f "$ALERT_FILE"
|
||||
/volume1/repos/nas-tools/notify.sh "✅ Dashboard Production Health Restored"
|
||||
fi
|
||||
|
||||
echo "$(date): ✅ All health checks passed"
|
||||
Executable
+79
@@ -0,0 +1,79 @@
|
||||
#!/bin/bash
|
||||
# Smoke test for dashboard deployment
|
||||
# Usage: ./smoke-test-dashboard.sh [dev|prod]
|
||||
|
||||
set -e
|
||||
|
||||
ENV=${1:-dev}
|
||||
if [ "$ENV" = "dev" ]; then
|
||||
BASE_URL="http://nas.jimmygan.com:4001"
|
||||
CONTAINER="nas-dashboard-dev"
|
||||
else
|
||||
BASE_URL="http://nas.jimmygan.com:4000"
|
||||
CONTAINER="nas-dashboard"
|
||||
fi
|
||||
|
||||
echo "=== Dashboard Smoke Test ($ENV) ==="
|
||||
echo "Base URL: $BASE_URL"
|
||||
echo ""
|
||||
|
||||
# Test 1: Health check
|
||||
echo "1. Testing health endpoint..."
|
||||
HEALTH=$(curl -s "$BASE_URL/api/health" --max-time 5)
|
||||
if echo "$HEALTH" | grep -q '"status":"ok"'; then
|
||||
echo "✅ Health check passed"
|
||||
else
|
||||
echo "❌ Health check failed: $HEALTH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 2: Frontend loads
|
||||
echo "2. Testing frontend loads..."
|
||||
FRONTEND=$(curl -s "$BASE_URL/" --max-time 5 | grep -c "NAS Dashboard" || echo "0")
|
||||
if [ "$FRONTEND" -gt 0 ]; then
|
||||
echo "✅ Frontend loads"
|
||||
else
|
||||
echo "❌ Frontend failed to load"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 3: API endpoints respond (should require auth)
|
||||
echo "3. Testing API endpoints require auth..."
|
||||
DATES=$(curl -s "$BASE_URL/api/conversations/dates" --max-time 5)
|
||||
if echo "$DATES" | grep -q "Not authenticated"; then
|
||||
echo "✅ Conversation tracker API requires auth"
|
||||
else
|
||||
echo "❌ Unexpected response: $DATES"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 4: Check for double /api/ prefix bug
|
||||
echo "4. Testing for double /api/ prefix bug..."
|
||||
DOUBLE_API=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/api/api/conversations/dates" --max-time 5)
|
||||
if [ "$DOUBLE_API" = "404" ]; then
|
||||
echo "✅ No double /api/ prefix bug"
|
||||
else
|
||||
echo "⚠️ Warning: /api/api/ path returned $DOUBLE_API (should be 404)"
|
||||
fi
|
||||
|
||||
# Test 5: Container health
|
||||
echo "5. Checking container health..."
|
||||
HEALTH_STATUS=$(ssh nas "/volume1/@appstore/ContainerManager/usr/bin/docker inspect --format='{{.State.Health.Status}}' $CONTAINER" 2>/dev/null || echo "unknown")
|
||||
if [ "$HEALTH_STATUS" = "healthy" ]; then
|
||||
echo "✅ Container is healthy"
|
||||
else
|
||||
echo "❌ Container health: $HEALTH_STATUS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Test 6: Check recent logs for errors
|
||||
echo "6. Checking for recent errors in logs..."
|
||||
ERROR_COUNT=$(ssh nas "/volume1/@appstore/ContainerManager/usr/bin/docker logs $CONTAINER --tail 50 2>&1 | grep -c 'ERROR' || echo 0")
|
||||
if [ "$ERROR_COUNT" -lt 5 ]; then
|
||||
echo "✅ No critical errors in recent logs ($ERROR_COUNT errors)"
|
||||
else
|
||||
echo "⚠️ Warning: Found $ERROR_COUNT errors in recent logs"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== All smoke tests passed! ==="
|
||||
Reference in New Issue
Block a user