Files
synapbus/internal/api/analytics_handler_test.go
T
Algis DumbrisandClaude Opus 4.6 30d62de350 feat: v0.7.0 — analytics dashboard, PWA, UX fixes, MCP prompts
Analytics: time-series message graph with 5 time spans (1h/4h/24h/7d/30d),
top-5 agents and channels leaderboards, summary cards. 4 new REST endpoints.

PWA: web app manifest, service worker with cache-first static/network-only
API strategy, push notifications via Web Push API with VAPID keys, push
subscription management endpoints, SQLite migration for subscriptions.

UX fixes: auto-resize compose textarea (3-12 lines), inline editable agent
display name, editable human display name in settings, smart mention/channel
highlighting (existing→link, deleted→inactive badge, unknown→plain text),
font size -/+ preference (12-24px persisted in localStorage), version footer
with GitHub link.

MCP: 4 prompts — daily-digest, agent-health-check, channel-overview,
debug-agent. Registered with prompt capabilities enabled.

Code review fixes: scoped push unsubscribe to user, capped analytics limit
at 100, hardened HTML strip regex, bounded SW cache, backend push unsub on
disable.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 10:36:58 +02:00

557 lines
15 KiB
Go

package api
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
_ "modernc.org/sqlite"
"github.com/synapbus/synapbus/internal/agents"
"github.com/synapbus/synapbus/internal/channels"
"github.com/synapbus/synapbus/internal/messaging"
"github.com/synapbus/synapbus/internal/storage"
)
func newAnalyticsTestDB(t *testing.T) *sql.DB {
t.Helper()
dsn := fmt.Sprintf("file:analytics_%s?mode=memory&cache=shared", t.Name())
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
t.Cleanup(func() { db.Close() })
if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil {
t.Fatalf("enable foreign keys: %v", err)
}
ctx := context.Background()
if err := storage.RunMigrations(ctx, db); err != nil {
t.Fatalf("run migrations: %v", err)
}
// Seed test users
db.Exec(`INSERT OR IGNORE INTO users (id, username, password_hash, display_name) VALUES (1, 'testuser', 'hash', 'Test User')`)
return db
}
func seedAnalyticsAgent(t *testing.T, db *sql.DB, name, displayName string, ownerID int64) {
t.Helper()
_, err := db.Exec(
`INSERT OR IGNORE INTO agents (name, display_name, type, capabilities, owner_id, api_key_hash, status) VALUES (?, ?, 'ai', '{}', ?, 'testhash', 'active')`,
name, displayName, ownerID,
)
if err != nil {
t.Fatalf("seed agent %s: %v", name, err)
}
}
func seedAnalyticsChannel(t *testing.T, db *sql.DB, name, createdBy string) int64 {
t.Helper()
result, err := db.Exec(
`INSERT INTO channels (name, description, type, is_private, created_by, created_at, updated_at) VALUES (?, '', 'standard', 0, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
name, createdBy,
)
if err != nil {
t.Fatalf("seed channel %s: %v", name, err)
}
id, _ := result.LastInsertId()
return id
}
func seedAnalyticsMessage(t *testing.T, db *sql.DB, from, to, body string, channelID *int64, createdAt time.Time) {
t.Helper()
// Ensure a conversation exists
result, err := db.Exec(
`INSERT INTO conversations (subject, created_by, created_at, updated_at) VALUES ('test', ?, ?, ?)`,
from, createdAt.Format("2006-01-02 15:04:05"), createdAt.Format("2006-01-02 15:04:05"),
)
if err != nil {
t.Fatalf("seed conversation: %v", err)
}
convID, _ := result.LastInsertId()
_, err = db.Exec(
`INSERT INTO messages (conversation_id, from_agent, to_agent, channel_id, body, priority, status, metadata, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 5, 'pending', '{}', ?, ?)`,
convID, from, to, channelID, body, createdAt.Format("2006-01-02 15:04:05"), createdAt.Format("2006-01-02 15:04:05"),
)
if err != nil {
t.Fatalf("seed message: %v", err)
}
}
func setupAnalyticsRouter(t *testing.T, db *sql.DB) chi.Router {
t.Helper()
agentStore := agents.NewSQLiteAgentStore(db)
agentService := agents.NewAgentService(agentStore, nil)
channelStore := channels.NewSQLiteChannelStore(db)
msgStore := messaging.NewSQLiteMessageStore(db)
msgService := messaging.NewMessagingService(msgStore, nil)
channelService := channels.NewService(channelStore, msgService, nil)
analyticsHandler := NewAnalyticsHandler(db, agentService, channelService)
router := chi.NewRouter()
router.Group(func(r chi.Router) {
r.Use(OwnerAuthMiddleware)
r.Get("/api/analytics/timeline", analyticsHandler.Timeline)
r.Get("/api/analytics/top-agents", analyticsHandler.TopAgents)
r.Get("/api/analytics/top-channels", analyticsHandler.TopChannels)
r.Get("/api/analytics/summary", analyticsHandler.Summary)
})
return router
}
func analyticsRequest(t *testing.T, router chi.Router, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
req.Header.Set("X-Owner-ID", "1")
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
return rr
}
func TestAnalyticsTimeline(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
seedAnalyticsAgent(t, db, "agent-b", "Agent B", 1)
now := time.Now().UTC()
// Seed messages at various times within the last 24h
for i := 0; i < 5; i++ {
seedAnalyticsMessage(t, db, "agent-a", "agent-b", fmt.Sprintf("msg-%d", i), nil, now.Add(-time.Duration(i)*time.Hour))
}
router := setupAnalyticsRouter(t, db)
tests := []struct {
name string
span string
wantStatus int
wantNonEmpty bool
}{
{
name: "default span (24h)",
span: "",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "1h span",
span: "1h",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "4h span",
span: "4h",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "24h span",
span: "24h",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "7d span",
span: "7d",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "30d span",
span: "30d",
wantStatus: http.StatusOK,
wantNonEmpty: true,
},
{
name: "invalid span",
span: "99x",
wantStatus: http.StatusBadRequest,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := "/api/analytics/timeline"
if tt.span != "" {
path += "?span=" + tt.span
}
rr := analyticsRequest(t, router, "GET", path)
if rr.Code != tt.wantStatus {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, tt.wantStatus, rr.Body.String())
}
if tt.wantStatus != http.StatusOK {
return
}
var resp struct {
Span string `json:"span"`
Buckets []struct {
Time string `json:"time"`
Count int `json:"count"`
} `json:"buckets"`
Total int `json:"total"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
expectedSpan := tt.span
if expectedSpan == "" {
expectedSpan = "24h"
}
if resp.Span != expectedSpan {
t.Errorf("span = %q, want %q", resp.Span, expectedSpan)
}
if tt.wantNonEmpty && resp.Total == 0 {
t.Error("expected non-zero total")
}
// Verify total matches sum of bucket counts
bucketSum := 0
for _, b := range resp.Buckets {
bucketSum += b.Count
}
if bucketSum != resp.Total {
t.Errorf("bucket sum %d != total %d", bucketSum, resp.Total)
}
})
}
}
func TestAnalyticsTimeline_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/timeline?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Span string `json:"span"`
Buckets []any `json:"buckets"`
Total int `json:"total"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.Total != 0 {
t.Errorf("total = %d, want 0", resp.Total)
}
if len(resp.Buckets) != 0 {
t.Errorf("buckets = %d, want 0", len(resp.Buckets))
}
}
func TestAnalyticsTopAgents(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-alpha", "Alpha Agent", 1)
seedAnalyticsAgent(t, db, "agent-beta", "Beta Agent", 1)
now := time.Now().UTC()
// agent-alpha sends 5 messages, agent-beta sends 2
for i := 0; i < 5; i++ {
seedAnalyticsMessage(t, db, "agent-alpha", "agent-beta", fmt.Sprintf("msg-%d", i), nil, now.Add(-time.Duration(i)*time.Minute))
}
for i := 0; i < 2; i++ {
seedAnalyticsMessage(t, db, "agent-beta", "agent-alpha", fmt.Sprintf("reply-%d", i), nil, now.Add(-time.Duration(i)*time.Minute))
}
router := setupAnalyticsRouter(t, db)
t.Run("returns agents sorted by count", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Span string `json:"span"`
Agents []struct {
Name string `json:"name"`
DisplayName string `json:"display_name"`
Count int `json:"count"`
} `json:"agents"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Agents) < 2 {
t.Fatalf("agents = %d, want >= 2", len(resp.Agents))
}
// First agent should be alpha (5 messages)
if resp.Agents[0].Name != "agent-alpha" {
t.Errorf("top agent = %q, want agent-alpha", resp.Agents[0].Name)
}
if resp.Agents[0].Count != 5 {
t.Errorf("top agent count = %d, want 5", resp.Agents[0].Count)
}
if resp.Agents[0].DisplayName != "Alpha Agent" {
t.Errorf("display_name = %q, want 'Alpha Agent'", resp.Agents[0].DisplayName)
}
// Second agent should be beta (2 messages)
if resp.Agents[1].Name != "agent-beta" {
t.Errorf("second agent = %q, want agent-beta", resp.Agents[1].Name)
}
if resp.Agents[1].Count != 2 {
t.Errorf("second agent count = %d, want 2", resp.Agents[1].Count)
}
})
t.Run("respects limit parameter", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h&limit=1")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Agents []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"agents"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Agents) != 1 {
t.Errorf("agents = %d, want 1", len(resp.Agents))
}
})
t.Run("invalid span returns error", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=invalid")
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
})
}
func TestAnalyticsTopAgents_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-agents?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Agents []any `json:"agents"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Agents) != 0 {
t.Errorf("agents = %d, want 0", len(resp.Agents))
}
}
func TestAnalyticsTopChannels(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
chID1 := seedAnalyticsChannel(t, db, "news-mcp", "agent-a")
chID2 := seedAnalyticsChannel(t, db, "general", "agent-a")
now := time.Now().UTC()
// news-mcp gets 4 messages, general gets 2
for i := 0; i < 4; i++ {
seedAnalyticsMessage(t, db, "agent-a", "", fmt.Sprintf("ch-msg-%d", i), &chID1, now.Add(-time.Duration(i)*time.Minute))
}
for i := 0; i < 2; i++ {
seedAnalyticsMessage(t, db, "agent-a", "", fmt.Sprintf("gen-msg-%d", i), &chID2, now.Add(-time.Duration(i)*time.Minute))
}
router := setupAnalyticsRouter(t, db)
t.Run("returns channels sorted by count", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
Span string `json:"span"`
Channels []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"channels"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) < 2 {
t.Fatalf("channels = %d, want >= 2", len(resp.Channels))
}
if resp.Channels[0].Name != "news-mcp" {
t.Errorf("top channel = %q, want news-mcp", resp.Channels[0].Name)
}
if resp.Channels[0].Count != 4 {
t.Errorf("top channel count = %d, want 4", resp.Channels[0].Count)
}
if resp.Channels[1].Name != "general" {
t.Errorf("second channel = %q, want general", resp.Channels[1].Name)
}
if resp.Channels[1].Count != 2 {
t.Errorf("second channel count = %d, want 2", resp.Channels[1].Count)
}
})
t.Run("respects limit parameter", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h&limit=1")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Channels []struct {
Name string `json:"name"`
Count int `json:"count"`
} `json:"channels"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) != 1 {
t.Errorf("channels = %d, want 1", len(resp.Channels))
}
})
t.Run("invalid span returns error", func(t *testing.T) {
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=invalid")
if rr.Code != http.StatusBadRequest {
t.Errorf("status = %d, want %d", rr.Code, http.StatusBadRequest)
}
})
}
func TestAnalyticsTopChannels_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/top-channels?span=24h")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
Channels []any `json:"channels"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if len(resp.Channels) != 0 {
t.Errorf("channels = %d, want 0", len(resp.Channels))
}
}
func TestAnalyticsSummary(t *testing.T) {
db := newAnalyticsTestDB(t)
seedAnalyticsAgent(t, db, "agent-a", "Agent A", 1)
seedAnalyticsAgent(t, db, "agent-b", "Agent B", 1)
chID := seedAnalyticsChannel(t, db, "test-channel", "agent-a")
now := time.Now().UTC()
seedAnalyticsMessage(t, db, "agent-a", "agent-b", "hello", nil, now)
seedAnalyticsMessage(t, db, "agent-b", "agent-a", "hi back", nil, now)
seedAnalyticsMessage(t, db, "agent-a", "", "channel msg", &chID, now)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/summary")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d, body: %s", rr.Code, http.StatusOK, rr.Body.String())
}
var resp struct {
TotalAgents int `json:"total_agents"`
TotalChannels int `json:"total_channels"`
TotalMessages int `json:"total_messages"`
}
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp.TotalAgents != 2 {
t.Errorf("total_agents = %d, want 2", resp.TotalAgents)
}
if resp.TotalChannels != 1 {
t.Errorf("total_channels = %d, want 1", resp.TotalChannels)
}
if resp.TotalMessages != 3 {
t.Errorf("total_messages = %d, want 3", resp.TotalMessages)
}
}
func TestAnalyticsSummary_EmptyDB(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
rr := analyticsRequest(t, router, "GET", "/api/analytics/summary")
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK)
}
var resp struct {
TotalAgents int `json:"total_agents"`
TotalChannels int `json:"total_channels"`
TotalMessages int `json:"total_messages"`
}
json.Unmarshal(rr.Body.Bytes(), &resp)
if resp.TotalAgents != 0 {
t.Errorf("total_agents = %d, want 0", resp.TotalAgents)
}
if resp.TotalChannels != 0 {
t.Errorf("total_channels = %d, want 0", resp.TotalChannels)
}
if resp.TotalMessages != 0 {
t.Errorf("total_messages = %d, want 0", resp.TotalMessages)
}
}
func TestAnalytics_Unauthenticated(t *testing.T) {
db := newAnalyticsTestDB(t)
router := setupAnalyticsRouter(t, db)
endpoints := []string{
"/api/analytics/timeline",
"/api/analytics/top-agents",
"/api/analytics/top-channels",
"/api/analytics/summary",
}
for _, endpoint := range endpoints {
t.Run(endpoint, func(t *testing.T) {
req := httptest.NewRequest("GET", endpoint, nil)
// No X-Owner-ID header
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want %d", rr.Code, http.StatusUnauthorized)
}
})
}
}