From b5b6f9134bacb70b634ec796c552486e0b1c71c5 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sat, 18 Apr 2026 23:57:07 +0800 Subject: [PATCH 1/7] fix: use native navigation for file downloads to support large files --- dashboard/frontend/src/lib/api.js | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index 864b0e0..bb0abc7 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -217,14 +217,8 @@ 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); + // Navigate directly to download URL - browser handles streaming natively + window.location.href = `${BASE}/files/download?token=${downloadToken}`; } catch (e) { console.error("Download error:", e); throw e; From 13be35383d4c6433b61687449880570c50b1f813 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 19 Apr 2026 00:14:56 +0800 Subject: [PATCH 2/7] chore: bump dashboard version to trigger deployment --- dashboard/backend/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dashboard/backend/config.py b/dashboard/backend/config.py index c038243..10f2526 100644 --- a/dashboard/backend/config.py +++ b/dashboard/backend/config.py @@ -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", "") From 66d5f4f7d43b23bf7d6eb10a1f11f1b90bd3d39c Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 19 Apr 2026 00:47:22 +0800 Subject: [PATCH 3/7] fix: use hidden iframe for downloads to avoid popup blockers --- dashboard/frontend/src/lib/api.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/dashboard/frontend/src/lib/api.js b/dashboard/frontend/src/lib/api.js index bb0abc7..1f0b2c7 100644 --- a/dashboard/frontend/src/lib/api.js +++ b/dashboard/frontend/src/lib/api.js @@ -217,8 +217,16 @@ export async function download(path, filename) { const { token: downloadToken } = await tokenResponse.json(); - // Navigate directly to download URL - browser handles streaming natively - window.location.href = `${BASE}/files/download?token=${downloadToken}`; + // 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; From 14d133cd64bb689673638339cd89e505c16ca619 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 19 Apr 2026 20:18:10 +0800 Subject: [PATCH 4/7] fix: correct API prefix for conversation tracker endpoints --- dashboard/backend/main.py | 1 + dashboard/backend/routers/conversation_tracker.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/dashboard/backend/main.py b/dashboard/backend/main.py index 4888eb0..6a69e4c 100644 --- a/dashboard/backend/main.py +++ b/dashboard/backend/main.py @@ -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( diff --git a/dashboard/backend/routers/conversation_tracker.py b/dashboard/backend/routers/conversation_tracker.py index e1012e5..07466f5 100644 --- a/dashboard/backend/routers/conversation_tracker.py +++ b/dashboard/backend/routers/conversation_tracker.py @@ -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") From c18d67779766b0b018cbf489310cfb056f2c6698 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 19 Apr 2026 20:19:36 +0800 Subject: [PATCH 5/7] fix: remove downloading state that blocks download button --- dashboard/frontend/src/routes/Files.svelte | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/dashboard/frontend/src/routes/Files.svelte b/dashboard/frontend/src/routes/Files.svelte index bb575ed..f4cead6 100644 --- a/dashboard/frontend/src/routes/Files.svelte +++ b/dashboard/frontend/src/routes/Files.svelte @@ -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 @@ {e.is_dir ? "—" : formatSize(e.size)}
{#if !e.is_dir} - {/if} From 9b8d7cfb00c06294a74e19f8619a7663f8392916 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 19 Apr 2026 20:40:08 +0800 Subject: [PATCH 6/7] fix: remove /api prefix from conversation API calls in frontend --- .../frontend/src/routes/Conversations.svelte | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/dashboard/frontend/src/routes/Conversations.svelte b/dashboard/frontend/src/routes/Conversations.svelte index f9af7df..93cd15e 100644 --- a/dashboard/frontend/src/routes/Conversations.svelte +++ b/dashboard/frontend/src/routes/Conversations.svelte @@ -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"; From 01fcb53a3a0805d6f52c0cf65d821d652d1da752 Mon Sep 17 00:00:00 2001 From: "Gan, Jimmy" Date: Sun, 19 Apr 2026 21:54:13 +0800 Subject: [PATCH 7/7] feat: add comprehensive testing mechanism for dashboard features - Add smoke test script for post-deployment verification - Add health monitoring script with Telegram alerts - Add backend integration tests for conversation tracker API - Add frontend tests to verify correct API paths - Update CI/CD workflows to enforce test failures and run smoke tests - Add comprehensive testing documentation This testing mechanism will catch issues like the double /api/ prefix bug before they reach users. --- .gitea/workflows/deploy-dev.yml | 28 ++ .gitea/workflows/test.yml | 14 + dashboard/backend/tests/conftest.py | 61 +++ .../integration/test_conversation_tracker.py | 81 ++++ .../tests/integration/conversations.test.js | 130 +++++++ docs/TESTING.md | 360 ++++++++++++++++++ scripts/monitor-dashboard-health.sh | 48 +++ scripts/smoke-test-dashboard.sh | 79 ++++ 8 files changed, 801 insertions(+) create mode 100644 dashboard/backend/tests/integration/test_conversation_tracker.py create mode 100644 dashboard/frontend/tests/integration/conversations.test.js create mode 100644 docs/TESTING.md create mode 100755 scripts/monitor-dashboard-health.sh create mode 100755 scripts/smoke-test-dashboard.sh diff --git a/.gitea/workflows/deploy-dev.yml b/.gitea/workflows/deploy-dev.yml index 07f8037..cdc719e 100644 --- a/.gitea/workflows/deploy-dev.yml +++ b/.gitea/workflows/deploy-dev.yml @@ -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 diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 6e45b48..04375b9 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -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 diff --git a/dashboard/backend/tests/conftest.py b/dashboard/backend/tests/conftest.py index de6d884..a51d5f6 100644 --- a/dashboard/backend/tests/conftest.py +++ b/dashboard/backend/tests/conftest.py @@ -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 diff --git a/dashboard/backend/tests/integration/test_conversation_tracker.py b/dashboard/backend/tests/integration/test_conversation_tracker.py new file mode 100644 index 0000000..ad23ae4 --- /dev/null +++ b/dashboard/backend/tests/integration/test_conversation_tracker.py @@ -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 diff --git a/dashboard/frontend/tests/integration/conversations.test.js b/dashboard/frontend/tests/integration/conversations.test.js new file mode 100644 index 0000000..9f50888 --- /dev/null +++ b/dashboard/frontend/tests/integration/conversations.test.js @@ -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', + }) + ); + }); +}); diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..775b0c4 --- /dev/null +++ b/docs/TESTING.md @@ -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 diff --git a/scripts/monitor-dashboard-health.sh b/scripts/monitor-dashboard-health.sh new file mode 100755 index 0000000..66aa466 --- /dev/null +++ b/scripts/monitor-dashboard-health.sh @@ -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" diff --git a/scripts/smoke-test-dashboard.sh b/scripts/smoke-test-dashboard.sh new file mode 100755 index 0000000..cc2c8ea --- /dev/null +++ b/scripts/smoke-test-dashboard.sh @@ -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! ==="