package server import ( "context" "encoding/json" "errors" "html/template" "net/http" "net/http/httptest" "slices" "strings" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/agentsview/internal/db" ) // testSession returns a *db.Session with sensible defaults. // Override fields after calling or via functional options. func testSession( opts ...func(*db.Session), ) *db.Session { s := &db.Session{ ID: "test-id", Project: "proj", Agent: "claude", MessageCount: 0, StartedAt: new("2025-01-15T10:00:00Z"), } for _, o := range opts { o(s) } return s } // stubServer returns an httptest.Server that responds with // the given status code and body. Caller must defer ts.Close(). func stubServer( t *testing.T, expectedMethod string, expectedToken string, status int, body string, ) *httptest.Server { t.Helper() return httptest.NewServer( http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, expectedMethod, r.Method) assert.Equal(t, "agentsview", r.Header.Get("User-Agent")) assert.Equal(t, "token "+expectedToken, r.Header.Get("Authorization")) w.WriteHeader(status) if body != "" { w.Write([]byte(body)) } }, ), ) } // assertErrorContains checks that err is non-nil and contains want. func assertErrorContains(t *testing.T, err error, want string) { t.Helper() require.Error(t, err, "expected error containing %q", want) assert.Contains(t, err.Error(), want) } // assertContextCancelled checks that err is non-nil and // wraps context.Canceled. func assertContextCancelled(t *testing.T, err error) { t.Helper() require.Error(t, err, "expected error for cancelled context") if !errors.Is(err, context.Canceled) && !strings.Contains( err.Error(), "context canceled", ) { assert.Fail(t, "expected context.Canceled", "got: %v", err, ) } } func TestFormatTimestamp(t *testing.T) { t.Parallel() tests := []struct { name string in string want string }{ { "RFC3339", "2025-01-15T10:30:00Z", "2025-01-15 10:30:00", }, { "RFC3339Nano", "2025-06-01T08:15:30.123456789Z", "2025-06-01 08:15:30", }, { "RFC3339_WithOffset", "2025-03-20T14:00:00+05:00", "2025-03-20 14:00:00", }, { "Empty", "", "", }, { "Unparseable_ReturnsRaw", "not-a-timestamp", "not-a-timestamp", }, { "Midnight", "2025-12-31T00:00:00Z", "2025-12-31 00:00:00", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := formatTimestamp(tt.in) assert.Equal(t, tt.want, got) }) } } func TestFormatDateShort(t *testing.T) { t.Parallel() tests := []struct { name string in *string want string }{ {"Nil", nil, "unknown"}, {"Empty", new(""), "unknown"}, { "Valid", new("2025-01-15T10:30:00Z"), "20250115", }, { "Nano", new("2025-06-01T08:15:30.999Z"), "20250601", }, { "Unparseable", new("garbage"), "unknown", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := formatDateShort(tt.in) assert.Equal(t, tt.want, got) }) } } func TestParseTimestamp(t *testing.T) { t.Parallel() tests := []struct { name string in string valid bool }{ {"RFC3339", "2025-01-15T10:30:00Z", true}, {"RFC3339Nano", "2025-01-15T10:30:00.123Z", true}, {"WithOffset", "2025-01-15T10:30:00+02:00", true}, {"Invalid", "January 15th", false}, {"Empty", "", false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() _, ok := parseTimestamp(tt.in) assert.Equal(t, tt.valid, ok) }) } } func TestFormatContentForExport_Escaping(t *testing.T) { t.Parallel() tests := []struct { name string input string contains []string excludes []string }{ { "HTMLEntitiesEscaped", ``, []string{ "<script>", "</script>", }, []string{"`, Timestamp: "2025-01-15T10:00:00Z", }, } out := generateExportHTML(session, msgs) // Template auto-escapes the tag in project name assert.NotContains(t, out, " tag not escaped") // Content is escaped by formatContentForExport assert.NotContains(t, out, "" }) child := &exportSessionTree{ Session: &db.Session{ ID: "child-a", Project: "proj", Agent: "claude", ParentSessionID: new("test-id"), RelationshipType: "subagent", }, Messages: []db.Message{{ SessionID: "child-a", Ordinal: 0, Role: "assistant", Content: "child message", }}, } msgs := []db.Message{{ SessionID: "test-id", Ordinal: 0, Role: "assistant", Content: "[Task]\none\n\n[Task]\ntwo", HasToolUse: true, ToolCalls: []db.ToolCall{ {ToolName: "Task", Category: "Task", ToolUseID: "toolu_1", SubagentSessionID: "child-a"}, {ToolName: "Task", Category: "Task", ToolUseID: "toolu_2", SubagentSessionID: "child-a"}, }, }} out := generateExportMarkdownTree(&exportSessionTree{ Session: session, Messages: msgs, AnchoredChildren: map[string]*exportSessionTree{"child-a": child}, }, exportMarkdownOptions{Depth: "all"}) assertContainsNone(t, out, []string{"# Session: proj\n"}) assert.Equal(t, 1, strings.Count(out, ``, }) assertContainsNone(t, out, []string{``, ``, ``, ``, ``, }) } func TestGenerateExportMarkdown_PreservesFollowingTextAfterMultilineBash(t *testing.T) { t.Parallel() session := testSession() cmd := "for x in a; do\n echo done\ndone" msgs := []db.Message{{ SessionID: "test-id", Ordinal: 0, Role: "assistant", Content: "[Bash]\n$ " + cmd + "\n\ndone", HasToolUse: true, ToolCalls: []db.ToolCall{{ ToolName: "Bash", Category: "Bash", InputJSON: `{"command":"` + "for x in a; do\\n echo done\\ndone" + `"}`, }}, }} out := generateExportMarkdown(session, msgs, exportMarkdownOptions{}) assertContainsAll(t, out, []string{ ``, "\ndone\n", }) } func TestGenerateExportMarkdown_SeparatesEmptyLegacyBlocks(t *testing.T) { t.Parallel() session := testSession() msgs := []db.Message{{ SessionID: "test-id", Ordinal: 0, Role: "assistant", Content: "[Task]\n[Grep TODO]\nbody two\n[Thinking]\n```txt\ncode only\n```", HasToolUse: true, }} out := generateExportMarkdown(session, msgs, exportMarkdownOptions{}) assertContainsAll(t, out, []string{ ``, ``, ``, ``, ``, }) } func TestGenerateExportMarkdown_PreservesInlineBracketsInLegacyBodies(t *testing.T) { t.Parallel() session := testSession() msgs := []db.Message{{ SessionID: "test-id", Ordinal: 0, Role: "assistant", Content: "[Bash]\n$ test [ -f foo ] && echo ```not fence```", HasToolUse: true, }} out := generateExportMarkdown(session, msgs, exportMarkdownOptions{}) assertContainsAll(t, out, []string{ ``, ``, }) } func TestSanitizeFilename(t *testing.T) { t.Parallel() tests := []struct { name string in string want string }{ {"Clean", "foo-bar.html", "foo-bar.html"}, {"Spaces", "my file.html", "my_file.html"}, { "SpecialChars", "a/b:c*d?.html", "a_b_c_d_.html", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := sanitizeFilename(tt.in) assert.Equal(t, tt.want, got) }) } } func TestTruncateStr(t *testing.T) { t.Parallel() tests := []struct { name string in string max int want string }{ {"Short", "hi", 10, "hi"}, {"Exact", "hello", 5, "hello"}, {"Long", "hello world", 5, "hello..."}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := truncateStr(tt.in, tt.max) assert.Equal(t, tt.want, got) }) } } // TestExportTemplateValid ensures the template parses and // renders without error for a minimal input. func TestExportTemplateValid(t *testing.T) { t.Parallel() data := exportData{ Project: "test", Agent: "Claude", MessageCount: 1, StartedAt: "2025-01-15 10:00:00", Messages: []exportMessage{ { RoleClass: "user", Role: "user", Timestamp: "2025-01-15 10:00:00", ContentHTML: template.HTML("hello"), }, }, } var b strings.Builder require.NoError(t, exportTmpl.Execute(&b, data), "template execution failed") assert.Contains(t, b.String(), "", "expected valid HTML doctype") } func TestExportTemplateAccentColors(t *testing.T) { t.Parallel() // Every accent color used by the frontend must be defined in // the export template so exported HTML renders agent colors. required := []string{ "--accent-blue", "--accent-rose", "--accent-purple", "--accent-amber", "--accent-green", "--accent-coral", "--accent-black", "--accent-teal", "--accent-red", "--accent-indigo", } for _, v := range required { assert.Contains(t, exportTemplateStr, v, "export template missing CSS variable %s", v) } } // --- GitHub API mock tests --- func TestCreateGist(t *testing.T) { t.Parallel() tests := []struct { name string respStatus int respBody string cancelCtx bool wantErr string wantID string wantURL string wantLogin string }{ { name: "Success", respStatus: http.StatusCreated, respBody: `{"id":"abc123","html_url":"https://gist.github.com/abc123","owner":{"login":"testuser"}}`, wantID: "abc123", wantURL: "https://gist.github.com/abc123", wantLogin: "testuser", }, { name: "APIError", respStatus: http.StatusUnprocessableEntity, respBody: `{"message":"Validation Failed"}`, wantErr: "422", }, { name: "MalformedJSON", respStatus: http.StatusOK, respBody: "not json", wantErr: "parsing", }, { name: "MissingFields", respStatus: http.StatusCreated, respBody: `{}`, }, { name: "ContextCancelled", respStatus: http.StatusOK, respBody: "", cancelCtx: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ts := stubServer(t, http.MethodPost, "tok", tt.respStatus, tt.respBody) defer ts.Close() ctx := context.Background() if tt.cancelCtx { var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) cancel() } got, err := createGistWithURL( ctx, ts.URL, "tok", "f.html", "desc", "content", true, ) if tt.cancelCtx { assertContextCancelled(t, err) return } if tt.wantErr != "" { assertErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) assert.Equal(t, tt.wantID, got.ID) assert.Equal(t, tt.wantURL, got.HTMLURL) assert.Equal(t, tt.wantLogin, got.Owner.Login) }) } } func TestCreateGistVisibility(t *testing.T) { t.Parallel() tests := []struct { name string public bool }{ {name: "Public", public: true}, {name: "Secret", public: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() var payload struct { Public bool `json:"public"` } ts := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { assert.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) w.WriteHeader(http.StatusCreated) w.Write([]byte(`{"id":"abc123",` + `"html_url":"https://gist.github.com/abc123",` + `"owner":{"login":"testuser"}}`)) }, )) defer ts.Close() _, err := createGistWithURL( context.Background(), ts.URL, "tok", "f.html", "desc", "content", tt.public, ) require.NoError(t, err) assert.Equal(t, tt.public, payload.Public) }) } } func TestResolveGitHubToken(t *testing.T) { originalGhAuthTokenOutput := ghAuthTokenOutput t.Cleanup(func() { ghAuthTokenOutput = originalGhAuthTokenOutput }) localCtx := context.WithValue(context.Background(), ctxKeyHumaRequestInfo, requestInfo{RemoteAddr: "127.0.0.1:1234"}) remoteCtx := context.WithValue(context.Background(), ctxKeyHumaRequestInfo, requestInfo{RemoteAddr: "127.0.0.1:1234", Forwarded: true}) tests := []struct { name string ctx context.Context configured string env string ghOutput string ghErr error want string }{ { name: "ConfiguredTokenWins", ctx: localCtx, configured: " saved-token ", env: "env-token", ghOutput: "gh-token\n", want: "saved-token", }, { name: "EnvFallback", ctx: localCtx, env: " env-token ", ghOutput: "gh-token\n", want: "env-token", }, { name: "GitHubCLIFallback", ctx: localCtx, ghOutput: "gh-token\n", want: "gh-token", }, { name: "MissingSources", ctx: localCtx, ghErr: errors.New("gh missing"), want: "", }, { name: "ConfiguredTokenAllowedForRemoteContext", ctx: remoteCtx, configured: " saved-token ", env: "env-token", ghOutput: "gh-token\n", want: "saved-token", }, { name: "EnvFallbackDeniedForRemoteContext", ctx: remoteCtx, env: " env-token ", ghOutput: "gh-token\n", want: "", }, { name: "GitHubCLIFallbackDeniedForRemoteContext", ctx: remoteCtx, ghOutput: "gh-token\n", want: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv("AGENTSVIEW_GITHUB_TOKEN", tt.env) ghAuthTokenOutput = func(context.Context) ([]byte, error) { if tt.ghErr != nil { return nil, tt.ghErr } return []byte(tt.ghOutput), nil } got := resolveGitHubToken(tt.ctx, tt.configured) assert.Equal(t, tt.want, got) }) } } func TestValidateGithubToken(t *testing.T) { t.Parallel() tests := []struct { name string respStatus int respBody string cancelCtx bool wantErr string wantLogin string }{ { name: "Success", respStatus: http.StatusOK, respBody: `{"login":"octocat"}`, wantLogin: "octocat", }, { name: "Unauthorized", respStatus: http.StatusUnauthorized, respBody: "", wantErr: "invalid", }, { name: "ServerError", respStatus: http.StatusInternalServerError, respBody: "", wantErr: "500", }, { name: "MalformedJSON", respStatus: http.StatusOK, respBody: "{broken", wantErr: "parsing", }, { name: "ContextCancelled", respStatus: http.StatusOK, respBody: "", cancelCtx: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() ts := stubServer(t, http.MethodGet, "tok", tt.respStatus, tt.respBody) defer ts.Close() ctx := context.Background() if tt.cancelCtx { var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) cancel() } login, err := validateGithubTokenWithURL( ctx, ts.URL, "tok", ) if tt.cancelCtx { assertContextCancelled(t, err) return } if tt.wantErr != "" { assertErrorContains(t, err, tt.wantErr) return } require.NoError(t, err) assert.Equal(t, tt.wantLogin, login) }) } } func TestGithubHTTPClientUsesIsolatedTransport(t *testing.T) { client := githubHTTPClient(10 * time.Second) require.NotNil(t, client.Transport) assert.NotSame(t, http.DefaultTransport, client.Transport, "github API clients must not share the package default transport") }