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>
557 lines
15 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|