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")
}