feat(ui): add maintenance page for task management
Some checks failed
CI / build-and-test (push) Failing after -31m53s
Some checks failed
CI / build-and-test (push) Failing after -31m53s
* Implement maintenance page with task and log display * Add backfill and metadata retry functionality * Integrate grid component for project display in thoughts page * Update types for maintenance tasks and logs * Enhance sidebar and shell for new maintenance navigation
This commit is contained in:
79
internal/app/admin_actions.go
Normal file
79
internal/app/admin_actions.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/tools"
|
||||
)
|
||||
|
||||
type adminActions struct {
|
||||
backfill *tools.BackfillTool
|
||||
retry *tools.EnrichmentRetryer
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func newAdminActions(backfill *tools.BackfillTool, retry *tools.EnrichmentRetryer, logger *slog.Logger) *adminActions {
|
||||
return &adminActions{
|
||||
backfill: backfill,
|
||||
retry: retry,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *adminActions) backfillHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var in tools.BackfillInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, out, err := a.backfill.Handle(r.Context(), nil, in)
|
||||
if err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.Warn("admin backfill failed", slog.String("error", err.Error()))
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *adminActions) retryMetadataHandler() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var in tools.RetryEnrichmentInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
http.Error(w, "invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
_, out, err := a.retry.Handle(r.Context(), nil, in)
|
||||
if err != nil {
|
||||
if a.logger != nil {
|
||||
a.logger.Warn("admin metadata retry failed", slog.String("error", err.Error()))
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
})
|
||||
}
|
||||
@@ -191,6 +191,7 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
filesTool := tools.NewFilesTool(db, activeProjects)
|
||||
enrichmentRetryer := tools.NewEnrichmentRetryer(context.Background(), db, bgMetadata, cfg.Capture, cfg.AI.Metadata.Timeout, activeProjects, logger)
|
||||
backfillTool := tools.NewBackfillTool(db, bgEmbeddings, activeProjects, logger)
|
||||
adminActions := newAdminActions(backfillTool, enrichmentRetryer, logger)
|
||||
|
||||
toolSet := mcpserver.ToolSet{
|
||||
Capture: tools.NewCaptureTool(db, embeddings, cfg.Capture, activeProjects, enrichmentRetryer, backfillTool),
|
||||
@@ -236,6 +237,8 @@ func routes(logger *slog.Logger, cfg *config.Config, info buildinfo.Info, db *st
|
||||
mux.HandleFunc("/api/oauth/register", oauthRegisterHandler(dynClients, logger))
|
||||
mux.HandleFunc("/api/oauth/authorize", oauthAuthorizeHandler(dynClients, authCodes, logger))
|
||||
mux.HandleFunc("/api/oauth/token", oauthTokenHandler(oauthRegistry, tokenStore, authCodes, logger))
|
||||
mux.Handle("/api/admin/actions/backfill", authMiddleware(adminActions.backfillHandler()))
|
||||
mux.Handle("/api/admin/actions/retry-metadata", authMiddleware(adminActions.retryMetadataHandler()))
|
||||
mux.HandleFunc("/favicon.ico", serveFavicon)
|
||||
mux.HandleFunc("/images/project.jpg", serveHomeImage)
|
||||
mux.HandleFunc("/images/icon.png", serveIcon)
|
||||
|
||||
@@ -61,6 +61,7 @@ func TestResolveSpecGuardAllowsSupportedMutations(t *testing.T) {
|
||||
entity string
|
||||
operation string
|
||||
}{
|
||||
{name: "learnings read", entity: "learnings", operation: "read"},
|
||||
{name: "projects create", entity: "projects", operation: "create"},
|
||||
{name: "thoughts update", entity: "thoughts", operation: "update"},
|
||||
{name: "thoughts delete", entity: "thoughts", operation: "delete"},
|
||||
@@ -119,6 +120,13 @@ func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.stored_files`,
|
||||
},
|
||||
{
|
||||
name: "mutations blocked for learnings",
|
||||
entity: "learnings",
|
||||
operation: "delete",
|
||||
wantCode: http.StatusForbidden,
|
||||
wantMessageIn: `operation "delete" is not allowed for public.learnings`,
|
||||
},
|
||||
{
|
||||
name: "unknown operation is rejected",
|
||||
entity: "projects",
|
||||
@@ -152,3 +160,13 @@ func TestResolveSpecGuardBlocksUnsupportedMutations(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveSpecModelsIncludeLearnings(t *testing.T) {
|
||||
models := resolveSpecModels()
|
||||
for _, model := range models {
|
||||
if model.schema == "public" && model.entity == "learnings" {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("resolveSpecModels() missing public.learnings")
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type statusAPIResponse struct {
|
||||
TotalKnown int `json:"total_known"`
|
||||
ConnectedWindow string `json:"connected_window"`
|
||||
Entries []auth.AccessSnapshot `json:"entries"`
|
||||
Metrics auth.AccessMetrics `json:"metrics"`
|
||||
OAuthEnabled bool `json:"oauth_enabled"`
|
||||
}
|
||||
|
||||
@@ -40,6 +41,7 @@ func statusSnapshot(info buildinfo.Info, tracker *auth.AccessTracker, oauthEnabl
|
||||
TotalKnown: len(entries),
|
||||
ConnectedWindow: "last 10 minutes",
|
||||
Entries: entries,
|
||||
Metrics: tracker.Metrics(20),
|
||||
OAuthEnabled: oauthEnabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,15 @@ func TestStatusSnapshotShowsTrackedAccess(t *testing.T) {
|
||||
if snapshot.Entries[0].KeyID != "client-a" || snapshot.Entries[0].LastPath != "/files" {
|
||||
t.Fatalf("entry = %+v, want keyID client-a and path /files", snapshot.Entries[0])
|
||||
}
|
||||
if snapshot.Metrics.TotalRequests != 1 {
|
||||
t.Fatalf("Metrics.TotalRequests = %d, want 1", snapshot.Metrics.TotalRequests)
|
||||
}
|
||||
if snapshot.Metrics.UniqueIPs != 1 {
|
||||
t.Fatalf("Metrics.UniqueIPs = %d, want 1", snapshot.Metrics.UniqueIPs)
|
||||
}
|
||||
if snapshot.Metrics.UniqueAgents != 1 {
|
||||
t.Fatalf("Metrics.UniqueAgents = %d, want 1", snapshot.Metrics.UniqueAgents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAPIHandlerReturnsJSON(t *testing.T) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
@@ -16,12 +18,19 @@ type AccessSnapshot struct {
|
||||
}
|
||||
|
||||
type AccessTracker struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]AccessSnapshot
|
||||
mu sync.RWMutex
|
||||
entries map[string]AccessSnapshot
|
||||
ipCounts map[string]int
|
||||
agentCounts map[string]int
|
||||
totalRequests int
|
||||
}
|
||||
|
||||
func NewAccessTracker() *AccessTracker {
|
||||
return &AccessTracker{entries: make(map[string]AccessSnapshot)}
|
||||
return &AccessTracker{
|
||||
entries: make(map[string]AccessSnapshot),
|
||||
ipCounts: make(map[string]int),
|
||||
agentCounts: make(map[string]int),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent string, now time.Time) {
|
||||
@@ -32,14 +41,36 @@ func (t *AccessTracker) Record(keyID, path, remoteAddr, userAgent string, now ti
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
normalizedRemoteAddr := normalizeRemoteAddr(remoteAddr)
|
||||
|
||||
entry := t.entries[keyID]
|
||||
entry.KeyID = keyID
|
||||
entry.LastPath = path
|
||||
entry.RemoteAddr = remoteAddr
|
||||
entry.RemoteAddr = normalizedRemoteAddr
|
||||
entry.UserAgent = userAgent
|
||||
entry.LastAccessedAt = now.UTC()
|
||||
entry.RequestCount++
|
||||
t.entries[keyID] = entry
|
||||
t.totalRequests++
|
||||
|
||||
if normalizedRemoteAddr != "" {
|
||||
t.ipCounts[normalizedRemoteAddr]++
|
||||
}
|
||||
if userAgent != "" {
|
||||
t.agentCounts[userAgent]++
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeRemoteAddr(value string) string {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
host, _, err := net.SplitHostPort(trimmed)
|
||||
if err == nil {
|
||||
return host
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
func (t *AccessTracker) Snapshot() []AccessSnapshot {
|
||||
@@ -79,3 +110,55 @@ func (t *AccessTracker) ConnectedCount(now time.Time, window time.Duration) int
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
type RequestAggregate struct {
|
||||
Key string `json:"key"`
|
||||
RequestCount int `json:"request_count"`
|
||||
}
|
||||
|
||||
type AccessMetrics struct {
|
||||
TotalRequests int `json:"total_requests"`
|
||||
UniquePrincipals int `json:"unique_principals"`
|
||||
UniqueIPs int `json:"unique_ips"`
|
||||
UniqueAgents int `json:"unique_agents"`
|
||||
TopIPs []RequestAggregate `json:"top_ips"`
|
||||
TopAgents []RequestAggregate `json:"top_agents"`
|
||||
}
|
||||
|
||||
func (t *AccessTracker) Metrics(topN int) AccessMetrics {
|
||||
if t == nil {
|
||||
return AccessMetrics{}
|
||||
}
|
||||
if topN <= 0 {
|
||||
topN = 10
|
||||
}
|
||||
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
|
||||
return AccessMetrics{
|
||||
TotalRequests: t.totalRequests,
|
||||
UniquePrincipals: len(t.entries),
|
||||
UniqueIPs: len(t.ipCounts),
|
||||
UniqueAgents: len(t.agentCounts),
|
||||
TopIPs: topAggregates(t.ipCounts, topN),
|
||||
TopAgents: topAggregates(t.agentCounts, topN),
|
||||
}
|
||||
}
|
||||
|
||||
func topAggregates(items map[string]int, topN int) []RequestAggregate {
|
||||
out := make([]RequestAggregate, 0, len(items))
|
||||
for key, count := range items {
|
||||
out = append(out, RequestAggregate{Key: key, RequestCount: count})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].RequestCount == out[j].RequestCount {
|
||||
return out[i].Key < out[j].Key
|
||||
}
|
||||
return out[i].RequestCount > out[j].RequestCount
|
||||
})
|
||||
if len(out) > topN {
|
||||
out = out[:topN]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ func TestAccessTrackerRecordAndSnapshot(t *testing.T) {
|
||||
if snap[0].UserAgent != "agent-a2" {
|
||||
t.Fatalf("snapshot[0].UserAgent = %q, want agent-a2", snap[0].UserAgent)
|
||||
}
|
||||
if snap[0].RemoteAddr != "10.0.0.1" {
|
||||
t.Fatalf("snapshot[0].RemoteAddr = %q, want 10.0.0.1", snap[0].RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTrackerConnectedCount(t *testing.T) {
|
||||
@@ -43,3 +46,42 @@ func TestAccessTrackerConnectedCount(t *testing.T) {
|
||||
t.Fatalf("ConnectedCount() = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessTrackerMetrics(t *testing.T) {
|
||||
tracker := NewAccessTracker()
|
||||
now := time.Date(2026, 4, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
tracker.Record("client-a", "/mcp", "10.0.0.1:1234", "agent-a", now)
|
||||
tracker.Record("client-a", "/mcp", "10.0.0.1:1234", "agent-a", now.Add(1*time.Second))
|
||||
tracker.Record("client-b", "/files", "10.0.0.2:5678", "agent-b", now.Add(2*time.Second))
|
||||
tracker.Record("client-c", "/files", "10.0.0.2:5678", "agent-b", now.Add(3*time.Second))
|
||||
|
||||
metrics := tracker.Metrics(5)
|
||||
if metrics.TotalRequests != 4 {
|
||||
t.Fatalf("TotalRequests = %d, want 4", metrics.TotalRequests)
|
||||
}
|
||||
if metrics.UniquePrincipals != 3 {
|
||||
t.Fatalf("UniquePrincipals = %d, want 3", metrics.UniquePrincipals)
|
||||
}
|
||||
if metrics.UniqueIPs != 2 {
|
||||
t.Fatalf("UniqueIPs = %d, want 2", metrics.UniqueIPs)
|
||||
}
|
||||
if metrics.UniqueAgents != 2 {
|
||||
t.Fatalf("UniqueAgents = %d, want 2", metrics.UniqueAgents)
|
||||
}
|
||||
if len(metrics.TopIPs) != 2 {
|
||||
t.Fatalf("len(TopIPs) = %d, want 2", len(metrics.TopIPs))
|
||||
}
|
||||
if metrics.TopIPs[0].RequestCount != 2 || metrics.TopIPs[1].RequestCount != 2 {
|
||||
t.Fatalf("TopIPs counts = %+v, want both counts to be 2", metrics.TopIPs)
|
||||
}
|
||||
if metrics.TopIPs[0].Key != "10.0.0.1" && metrics.TopIPs[0].Key != "10.0.0.2" {
|
||||
t.Fatalf("TopIPs[0].Key = %q, want normalized IP", metrics.TopIPs[0].Key)
|
||||
}
|
||||
if len(metrics.TopAgents) != 2 {
|
||||
t.Fatalf("len(TopAgents) = %d, want 2", len(metrics.TopAgents))
|
||||
}
|
||||
if metrics.TopAgents[0].RequestCount != 2 || metrics.TopAgents[1].RequestCount != 2 {
|
||||
t.Fatalf("TopAgents counts = %+v, want both counts to be 2", metrics.TopAgents)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,86 @@ func TestStreamableHTTPReturnsStructuredToolErrors(t *testing.T) {
|
||||
t.Fatalf("build_date = %#v, want %q", got["build_date"], "2026-03-31T00:00:00Z")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add_learning_requires_summary", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "add_learning",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(add_learning) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("add_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidArguments {
|
||||
t.Fatalf("add_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments)
|
||||
}
|
||||
if data.Field != "summary" {
|
||||
t.Fatalf("add_learning data.field = %q, want %q", data.Field, "summary")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get_learning_requires_id", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "get_learning",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(get_learning) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("get_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidArguments {
|
||||
t.Fatalf("get_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidArguments)
|
||||
}
|
||||
if data.Field != "id" {
|
||||
t.Fatalf("get_learning data.field = %q, want %q", data.Field, "id")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("add_learning_unconfigured_returns_structured_error", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "add_learning",
|
||||
Arguments: map[string]any{
|
||||
"summary": "Learning with configured check",
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(add_learning) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("add_learning code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("add_learning data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list_learnings_unconfigured_returns_structured_error", func(t *testing.T) {
|
||||
_, err := cs.CallTool(context.Background(), &mcp.CallToolParams{
|
||||
Name: "list_learnings",
|
||||
Arguments: map[string]any{},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("CallTool(list_learnings) error = nil, want error")
|
||||
}
|
||||
|
||||
rpcErr, data := requireWireError(t, err)
|
||||
if rpcErr.Code != jsonrpc.CodeInvalidParams {
|
||||
t.Fatalf("list_learnings code = %d, want %d", rpcErr.Code, jsonrpc.CodeInvalidParams)
|
||||
}
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("list_learnings data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func streamableTestToolSet() ToolSet {
|
||||
|
||||
@@ -75,6 +75,9 @@ func (t *LearningsTool) Add(ctx context.Context, req *mcp.CallToolRequest, in Ad
|
||||
if summary == "" {
|
||||
return nil, AddLearningOutput{}, errRequiredField("summary")
|
||||
}
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, AddLearningOutput{}, err
|
||||
}
|
||||
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
@@ -113,6 +116,10 @@ func (t *LearningsTool) Add(ctx context.Context, req *mcp.CallToolRequest, in Ad
|
||||
}
|
||||
|
||||
func (t *LearningsTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetLearningInput) (*mcp.CallToolResult, GetLearningOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, GetLearningOutput{}, err
|
||||
}
|
||||
|
||||
learning, err := t.store.GetLearning(ctx, in.ID)
|
||||
if err != nil {
|
||||
return nil, GetLearningOutput{}, err
|
||||
@@ -121,6 +128,10 @@ func (t *LearningsTool) Get(ctx context.Context, _ *mcp.CallToolRequest, in GetL
|
||||
}
|
||||
|
||||
func (t *LearningsTool) List(ctx context.Context, req *mcp.CallToolRequest, in ListLearningsInput) (*mcp.CallToolResult, ListLearningsOutput, error) {
|
||||
if err := t.ensureConfigured(); err != nil {
|
||||
return nil, ListLearningsOutput{}, err
|
||||
}
|
||||
|
||||
project, err := resolveProject(ctx, t.store, t.sessions, req, in.Project, false)
|
||||
if err != nil {
|
||||
return nil, ListLearningsOutput{}, err
|
||||
@@ -146,6 +157,13 @@ func (t *LearningsTool) List(ctx context.Context, req *mcp.CallToolRequest, in L
|
||||
return nil, ListLearningsOutput{Learnings: items}, nil
|
||||
}
|
||||
|
||||
func (t *LearningsTool) ensureConfigured() error {
|
||||
if t == nil || t.store == nil {
|
||||
return errInvalidInput("learnings tool is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func defaultString(value string, fallback string) string {
|
||||
if value == "" {
|
||||
return fallback
|
||||
|
||||
70
internal/tools/learnings_test.go
Normal file
70
internal/tools/learnings_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/mcperrors"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestLearningsAddRequiresSummary(t *testing.T) {
|
||||
tool := &LearningsTool{}
|
||||
|
||||
_, _, err := tool.Add(context.Background(), nil, AddLearningInput{})
|
||||
if err == nil {
|
||||
t.Fatal("Add() error = nil, want error")
|
||||
}
|
||||
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Field != "summary" {
|
||||
t.Fatalf("Add() error field = %q, want %q", data.Field, "summary")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLearningsMethodsRequireConfiguredStore(t *testing.T) {
|
||||
tool := &LearningsTool{}
|
||||
|
||||
t.Run("add", func(t *testing.T) {
|
||||
_, _, err := tool.Add(context.Background(), nil, AddLearningInput{Summary: "Keep this"})
|
||||
if err == nil {
|
||||
t.Fatal("Add() error = nil, want error")
|
||||
}
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("Add() data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
_, _, err := tool.Get(context.Background(), nil, GetLearningInput{ID: uuid.New()})
|
||||
if err == nil {
|
||||
t.Fatal("Get() error = nil, want error")
|
||||
}
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("Get() data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list", func(t *testing.T) {
|
||||
_, _, err := tool.List(context.Background(), nil, ListLearningsInput{})
|
||||
if err == nil {
|
||||
t.Fatal("List() error = nil, want error")
|
||||
}
|
||||
_, data := requireRPCError(t, err)
|
||||
if data.Type != mcperrors.TypeInvalidInput {
|
||||
t.Fatalf("List() data.type = %q, want %q", data.Type, mcperrors.TypeInvalidInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestNormalizeStringSliceTrimsDedupesAndDropsEmpties(t *testing.T) {
|
||||
got := normalizeStringSlice([]string{" alpha ", "beta", "", "beta", "alpha"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("normalizeStringSlice() len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0] != "alpha" || got[1] != "beta" {
|
||||
t.Fatalf("normalizeStringSlice() = %#v, want [alpha beta]", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user