package main
import (
"fmt"
"html/template"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// --- Test helpers ---
// testServer creates a server backed by a real git repository with sample content.
func testServer(t *testing.T) *server {
t.Helper()
tmpDir := t.TempDir()
// Create a bare repo.
repoDir := filepath.Join(tmpDir, "testrepo.git")
gitRun(t, "", "init", "--bare", repoDir)
// Mark it public and set metadata.
os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
os.WriteFile(filepath.Join(repoDir, "description"), []byte("A test repository"), 0644)
os.WriteFile(filepath.Join(repoDir, "owner"), []byte("tester"), 0644)
// Clone, add content, push.
workDir := filepath.Join(tmpDir, "work")
gitRun(t, "", "clone", repoDir, workDir)
gitRun(t, workDir, "config", "user.email", "test@example.com")
gitRun(t, workDir, "config", "user.name", "Test User")
// Create files in initial commit.
os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test Repo\n\nHello world.\n"), 0644)
os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0644)
os.MkdirAll(filepath.Join(workDir, "pkg"), 0755)
os.WriteFile(filepath.Join(workDir, "pkg", "lib.go"), []byte("package pkg\n"), 0644)
gitRun(t, workDir, "add", ".")
gitRun(t, workDir, "commit", "-m", "Initial commit")
gitRun(t, workDir, "push", "origin", "HEAD")
// Second commit.
os.WriteFile(filepath.Join(workDir, "extra.txt"), []byte("extra content\n"), 0644)
gitRun(t, workDir, "add", ".")
gitRun(t, workDir, "commit", "-m", "Add extra file")
gitRun(t, workDir, "push", "origin", "HEAD")
// Tag.
gitRun(t, workDir, "tag", "-a", "v1.0", "-m", "Release v1.0")
gitRun(t, workDir, "push", "origin", "v1.0")
// Feature branch with slash in name.
gitRun(t, workDir, "checkout", "-b", "feature/test")
os.WriteFile(filepath.Join(workDir, "feature.txt"), []byte("feature content\n"), 0644)
gitRun(t, workDir, "add", ".")
gitRun(t, workDir, "commit", "-m", "Add feature")
gitRun(t, workDir, "push", "origin", "feature/test")
git := newGitCLIBackend(repoDir)
defaultBranch := git.getDefaultBranch()
commit, _ := git.getCommit(defaultBranch)
repo := &RepoInfo{
Name: "testrepo",
Path: repoDir,
GitDir: repoDir,
Description: "A test repository",
Owner: "tester",
Git: git,
}
if commit != nil {
repo.LastUpdated = commit.AuthorDate
}
tmpl, err := loadTemplates()
if err != nil {
t.Fatalf("loadTemplates: %v", err)
}
db, err := openDB(filepath.Join(tmpDir, "test.db"))
if err != nil {
t.Fatalf("openDB: %v", err)
}
t.Cleanup(func() { db.Close() })
return &server{
repos: map[string]*RepoInfo{"testrepo": repo},
sorted: []string{"testrepo"},
tmpl: tmpl,
db: db,
title: "Test Forge",
description: "test site description",
baseURL: "",
scanPath: tmpDir,
}
}
func gitRun(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
if dir != "" {
cmd.Dir = dir
}
cmd.Env = append(os.Environ(),
"GIT_CONFIG_GLOBAL=/dev/null",
"GIT_CONFIG_SYSTEM=/dev/null",
)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v (dir=%s): %v\n%s", args, dir, err, out)
}
}
// get sends a GET through the route dispatcher.
func get(t *testing.T, srv *server, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
srv.route(w, req)
return w
}
// getWithCookie sends a GET with a session cookie.
func getWithCookie(t *testing.T, srv *server, path, handle string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)})
w := httptest.NewRecorder()
srv.route(w, req)
return w
}
// postForm sends a POST with form data through the route dispatcher.
func postForm(t *testing.T, srv *server, path string, form url.Values) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
srv.route(w, req)
return w
}
// postFormWithCookie sends a POST with form data and a session cookie.
func postFormWithCookie(t *testing.T, srv *server, path string, form url.Values, handle string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)})
w := httptest.NewRecorder()
srv.route(w, req)
return w
}
// getMux sends a GET through the full mux (including static asset routes).
func getMux(t *testing.T, srv *server, path string) *httptest.ResponseRecorder {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/style.css", srv.serveCSS)
mux.HandleFunc("/radiant.svg", srv.serveLogo)
mux.HandleFunc("/js/", srv.serveJS)
mux.HandleFunc("/fonts/", srv.serveFont)
mux.HandleFunc("/", srv.route)
req := httptest.NewRequest(http.MethodGet, path, nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
return w
}
// defaultRef returns the default branch name for the test repo.
func defaultRef(t *testing.T, srv *server) string {
t.Helper()
return srv.repos["testrepo"].Git.getDefaultBranch()
}
// latestHash returns the latest commit hash on the default branch.
func latestHash(t *testing.T, srv *server) string {
t.Helper()
git := srv.repos["testrepo"].Git
h, err := git.resolveRef(git.getDefaultBranch())
if err != nil {
t.Fatalf("resolveRef: %v", err)
}
return h
}
// initialHash returns the hash of the "Initial commit".
func initialHash(t *testing.T, srv *server) string {
t.Helper()
git := srv.repos["testrepo"].Git
h, _ := git.resolveRef(git.getDefaultBranch())
commits, _, _ := git.getLog(h, 0, 50)
for _, c := range commits {
if c.Subject == "Initial commit" {
return c.Hash
}
}
t.Fatal("initial commit not found")
return ""
}
func assertCode(t *testing.T, w *httptest.ResponseRecorder, want int) {
t.Helper()
if w.Code != want {
n := w.Body.Len()
if n > 500 {
n = 500
}
t.Fatalf("expected status %d, got %d\nbody: %s", want, w.Code, w.Body.String()[:n])
}
}
func assertContains(t *testing.T, w *httptest.ResponseRecorder, substr string) {
t.Helper()
if !strings.Contains(w.Body.String(), substr) {
n := w.Body.Len()
if n > 1000 {
n = 1000
}
t.Errorf("expected body to contain %q\nbody: %s", substr, w.Body.String()[:n])
}
}
func assertNotContains(t *testing.T, w *httptest.ResponseRecorder, substr string) {
t.Helper()
if strings.Contains(w.Body.String(), substr) {
t.Errorf("expected body NOT to contain %q", substr)
}
}
func assertHeader(t *testing.T, w *httptest.ResponseRecorder, key, wantSubstr string) {
t.Helper()
got := w.Header().Get(key)
if !strings.Contains(got, wantSubstr) {
t.Errorf("expected header %s to contain %q, got %q", key, wantSubstr, got)
}
}
func assertRedirect(t *testing.T, w *httptest.ResponseRecorder, code int, locSubstr string) {
t.Helper()
assertCode(t, w, code)
loc := w.Header().Get("Location")
if !strings.Contains(loc, locSubstr) {
t.Errorf("expected Location to contain %q, got %q", locSubstr, loc)
}
}
// ===================================================================
// Static assets
// ===================================================================
func TestServeCSS(t *testing.T) {
srv := testServer(t)
w := getMux(t, srv, "/style.css")
assertCode(t, w, 200)
assertHeader(t, w, "Content-Type", "text/css")
assertHeader(t, w, "Cache-Control", "public")
if w.Body.Len() == 0 {
t.Error("empty CSS body")
}
}
func TestServeLogo(t *testing.T) {
srv := testServer(t)
w := getMux(t, srv, "/radiant.svg")
assertCode(t, w, 200)
assertHeader(t, w, "Content-Type", "image/svg+xml")
assertHeader(t, w, "Cache-Control", "public")
if w.Body.Len() == 0 {
t.Error("empty logo body")
}
}
func TestServeJS_Known(t *testing.T) {
srv := testServer(t)
for _, name := range []string{"hirad.js", "hiril.js"} {
w := getMux(t, srv, "/js/"+name)
assertCode(t, w, 200)
assertHeader(t, w, "Content-Type", "javascript")
assertHeader(t, w, "Cache-Control", "public")
if w.Body.Len() == 0 {
t.Errorf("empty body for %s", name)
}
}
}
func TestServeJS_Unknown(t *testing.T) {
srv := testServer(t)
w := getMux(t, srv, "/js/unknown.js")
assertCode(t, w, 404)
}
func TestServeFont_Known(t *testing.T) {
srv := testServer(t)
w := getMux(t, srv, "/fonts/RethinkSans.ttf")
assertCode(t, w, 200)
assertHeader(t, w, "Content-Type", "font/ttf")
assertHeader(t, w, "Cache-Control", "public")
if w.Body.Len() == 0 {
t.Error("empty font body")
}
}
func TestServeFont_Unknown(t *testing.T) {
srv := testServer(t)
w := getMux(t, srv, "/fonts/nonexistent.ttf")
assertCode(t, w, 404)
}
// ===================================================================
// Index page
// ===================================================================
func TestIndex(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/")
assertCode(t, w, 200)
assertContains(t, w, "testrepo")
assertContains(t, w, "A test repository")
assertContains(t, w, "Test Forge")
assertContains(t, w, "test site description")
// Footer
assertContains(t, w, "Radiant Forge")
}
func TestIndex_Empty(t *testing.T) {
srv := testServer(t)
srv.repos = map[string]*RepoInfo{}
srv.sorted = nil
w := get(t, srv, "/")
assertCode(t, w, 200)
assertContains(t, w, "No repositories found")
}
func TestIndex_SignInLink(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/")
assertCode(t, w, 200)
assertContains(t, w, "Sign in")
}
func TestIndex_LoggedInShowsAvatar(t *testing.T) {
srv := testServer(t)
upsertAvatar(srv.db, "alice", "https://example.com/alice.png")
w := getWithCookie(t, srv, "/", "alice")
assertCode(t, w, 200)
assertContains(t, w, "https://example.com/alice.png")
assertContains(t, w, "Sign out")
}
// ===================================================================
// Repo summary (home)
// ===================================================================
func TestSummary(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/")
assertCode(t, w, 200)
assertContains(t, w, "testrepo")
assertContains(t, w, "A test repository")
// Navigation tabs
assertContains(t, w, "home")
assertContains(t, w, "log")
assertContains(t, w, "refs")
assertContains(t, w, "discussions")
// File tree
assertContains(t, w, "README.md")
assertContains(t, w, "main.go")
assertContains(t, w, "pkg")
assertContains(t, w, "extra.txt")
// README content
assertContains(t, w, "Hello world")
// Last commit
assertContains(t, w, "Add extra file")
assertContains(t, w, "Test User")
// Clone URLs
assertContains(t, w, "testrepo.git")
// No footer on repo pages
assertNotContains(t, w, "Powered by")
}
func TestSummary_NoTrailingSlash(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo")
assertCode(t, w, 200)
assertContains(t, w, "testrepo")
}
func TestSummary_BranchSelector(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/")
assertCode(t, w, 200)
assertContains(t, w, "feature/test")
}
func TestSummary_CloneHTTPS_XForwardedProto(t *testing.T) {
srv := testServer(t)
req := httptest.NewRequest(http.MethodGet, "/testrepo/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
w := httptest.NewRecorder()
srv.route(w, req)
assertCode(t, w, 200)
assertContains(t, w, "https://")
}
// ===================================================================
// Refs page
// ===================================================================
func TestRefs(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/refs")
assertCode(t, w, 200)
assertContains(t, w, "feature/test")
assertContains(t, w, "v1.0")
assertContains(t, w, "Branch")
assertContains(t, w, "Tag")
}
// ===================================================================
// Log page
// ===================================================================
func TestLog_Default(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/log/")
assertCode(t, w, 200)
assertContains(t, w, "Initial commit")
assertContains(t, w, "Add extra file")
assertContains(t, w, "Test User")
assertContains(t, w, "Hash")
assertContains(t, w, "Subject")
}
func TestLog_SpecificRef(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/log/feature/test")
assertCode(t, w, 200)
assertContains(t, w, "Add feature")
assertContains(t, w, "Initial commit")
}
func TestLog_Pagination(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/log/?page=0")
assertCode(t, w, 200)
assertContains(t, w, "Initial commit")
}
func TestLog_HighPage(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/log/"+ref+"?page=999")
assertCode(t, w, 200)
assertContains(t, w, "No commits found")
}
func TestLog_NegativePage(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/log/"+ref+"?page=-1")
assertCode(t, w, 200)
// Negative clamped to 0, should show commits.
assertContains(t, w, "Initial commit")
}
func TestLog_InvalidRef(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/log/nonexistent-ref")
assertCode(t, w, 200)
// Renders the empty log page.
}
func TestLog_BranchSelector(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/log/")
assertCode(t, w, 200)
assertContains(t, w, "feature/test")
}
// ===================================================================
// Tree page
// ===================================================================
func TestTree_Root(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/tree/"+ref)
assertCode(t, w, 200)
assertContains(t, w, "main.go")
assertContains(t, w, "pkg")
assertContains(t, w, "README.md")
}
func TestTree_Directory(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/tree/"+ref+"/pkg")
assertCode(t, w, 200)
assertContains(t, w, "lib.go")
}
func TestTree_File(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
assertCode(t, w, 200)
assertContains(t, w, "package main")
assertContains(t, w, "func main()")
assertContains(t, w, "main.go")
assertContains(t, w, "/raw/")
}
func TestTree_NestedFile(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/tree/"+ref+"/pkg/lib.go")
assertCode(t, w, 200)
assertContains(t, w, "package pkg")
}
func TestTree_BranchWithSlash(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/tree/feature/test")
assertCode(t, w, 200)
assertContains(t, w, "feature.txt")
}
func TestTree_BranchWithSlash_File(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/tree/feature/test/feature.txt")
assertCode(t, w, 200)
assertContains(t, w, "feature content")
}
func TestTree_EmptyRest(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/tree/")
assertCode(t, w, 200)
assertContains(t, w, "main.go")
}
func TestTree_InvalidRef(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/tree/nonexistent-ref")
assertCode(t, w, 404)
assertContains(t, w, "Ref not found")
}
func TestTree_FileNotFound(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/tree/"+ref+"/no-such-file.txt")
assertCode(t, w, 404)
assertContains(t, w, "File not found")
}
func TestTree_LineNumbers(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
assertCode(t, w, 200)
assertContains(t, w, `id="L1"`)
assertContains(t, w, `href="#L1"`)
}
func TestTree_FileShowsSize(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
assertCode(t, w, 200)
// formatSize should produce "X B" for small files.
assertContains(t, w, " B")
}
// ===================================================================
// Commit page
// ===================================================================
func TestCommit(t *testing.T) {
srv := testServer(t)
hash := latestHash(t, srv)
w := get(t, srv, "/testrepo/commit/"+hash)
assertCode(t, w, 200)
assertContains(t, w, "Add extra file")
assertContains(t, w, "Test User")
assertContains(t, w, hash)
assertContains(t, w, "extra.txt")
assertContains(t, w, "parent")
}
func TestCommit_Initial(t *testing.T) {
srv := testServer(t)
hash := initialHash(t, srv)
w := get(t, srv, "/testrepo/commit/"+hash)
assertCode(t, w, 200)
assertContains(t, w, "Initial commit")
assertContains(t, w, "README.md")
assertContains(t, w, "main.go")
}
func TestCommit_NotFound(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
assertCode(t, w, 404)
assertContains(t, w, "Commit not found")
}
func TestCommit_CommitHashInNav(t *testing.T) {
srv := testServer(t)
hash := latestHash(t, srv)
w := get(t, srv, "/testrepo/commit/"+hash)
assertCode(t, w, 200)
assertContains(t, w, hash[:8])
}
func TestCommit_DiffStats(t *testing.T) {
srv := testServer(t)
hash := latestHash(t, srv)
w := get(t, srv, "/testrepo/commit/"+hash)
assertCode(t, w, 200)
// Diff stats: +N -N
assertContains(t, w, "added")
}
// ===================================================================
// Raw file download
// ===================================================================
func TestRaw(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/raw/"+ref+"/main.go")
assertCode(t, w, 200)
assertHeader(t, w, "Content-Type", "text/plain")
if !strings.Contains(w.Body.String(), "package main") {
t.Error("expected raw file content")
}
cl := w.Header().Get("Content-Length")
if cl == "" || cl == "0" {
t.Error("expected non-zero Content-Length")
}
}
func TestRaw_Markdown(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/raw/"+ref+"/README.md")
assertCode(t, w, 200)
assertHeader(t, w, "Content-Type", "text/plain")
assertContains(t, w, "# Test Repo")
}
func TestRaw_NoPath(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/raw/")
assertCode(t, w, 404)
assertContains(t, w, "No path specified")
}
func TestRaw_RefOnly(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/raw/"+ref)
assertCode(t, w, 404)
assertContains(t, w, "No file path")
}
func TestRaw_InvalidRef(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/raw/nonexistent/file.txt")
assertCode(t, w, 404)
assertContains(t, w, "Ref not found")
}
func TestRaw_FileNotFound(t *testing.T) {
srv := testServer(t)
ref := defaultRef(t, srv)
w := get(t, srv, "/testrepo/raw/"+ref+"/no-such-file.txt")
assertCode(t, w, 404)
assertContains(t, w, "File not found")
}
// ===================================================================
// Routing: repo not found, unknown action, .git suffix
// ===================================================================
func TestRoute_RepoNotFound(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/nonexistent/")
assertCode(t, w, 404)
assertContains(t, w, "Repository not found")
}
func TestRoute_UnknownAction(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/foobar")
assertCode(t, w, 404)
assertContains(t, w, "Page not found")
}
func TestRoute_RepoNameDotGit(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo.git/refs")
assertCode(t, w, 200)
assertContains(t, w, "feature/test")
}
// ===================================================================
// Base URL prefix
// ===================================================================
func TestBaseURL_Index(t *testing.T) {
srv := testServer(t)
srv.baseURL = "/git"
req := httptest.NewRequest(http.MethodGet, "/git/", nil)
w := httptest.NewRecorder()
srv.route(w, req)
assertCode(t, w, 200)
assertContains(t, w, "/git/testrepo/")
}
func TestBaseURL_Summary(t *testing.T) {
srv := testServer(t)
srv.baseURL = "/git"
req := httptest.NewRequest(http.MethodGet, "/git/testrepo/", nil)
w := httptest.NewRecorder()
srv.route(w, req)
assertCode(t, w, 200)
assertContains(t, w, "/git/testrepo/")
}
func TestBaseURL_LinksInRepoPages(t *testing.T) {
srv := testServer(t)
srv.baseURL = "/code"
req := httptest.NewRequest(http.MethodGet, "/code/testrepo/refs", nil)
w := httptest.NewRecorder()
srv.route(w, req)
assertCode(t, w, 200)
assertContains(t, w, "/code/testrepo/log/")
assertContains(t, w, "/code/testrepo/commit/")
}
// ===================================================================
// Basic auth
// ===================================================================
func TestBasicAuth_NoCredentials(t *testing.T) {
srv := testServer(t)
srv.username = "admin"
srv.password = "secret"
mux := http.NewServeMux()
mux.HandleFunc("/", srv.route)
handler := srv.basicAuth(mux)
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assertCode(t, w, 401)
assertHeader(t, w, "WWW-Authenticate", "Basic")
}
func TestBasicAuth_WrongCredentials(t *testing.T) {
srv := testServer(t)
srv.username = "admin"
srv.password = "secret"
mux := http.NewServeMux()
mux.HandleFunc("/", srv.route)
handler := srv.basicAuth(mux)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("admin", "wrong")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assertCode(t, w, 401)
}
func TestBasicAuth_CorrectCredentials(t *testing.T) {
srv := testServer(t)
srv.username = "admin"
srv.password = "secret"
mux := http.NewServeMux()
mux.HandleFunc("/", srv.route)
handler := srv.basicAuth(mux)
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.SetBasicAuth("admin", "secret")
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
assertCode(t, w, 200)
}
// ===================================================================
// Error page
// ===================================================================
func TestRenderError(t *testing.T) {
srv := testServer(t)
req := httptest.NewRequest(http.MethodGet, "/bad-path", nil)
w := httptest.NewRecorder()
srv.renderError(w, req, 404, "Something went wrong")
assertCode(t, w, 404)
assertContains(t, w, "Something went wrong")
assertContains(t, w, "404")
assertContains(t, w, "/bad-path")
assertContains(t, w, "Back to index")
}
// ===================================================================
// Discussions
// ===================================================================
func TestDiscussions_ListEmpty(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/discussions")
assertCode(t, w, 200)
assertContains(t, w, "No discussions yet")
assertContains(t, w, "discussions")
}
func TestDiscussions_ListWithItems(t *testing.T) {
srv := testServer(t)
createDiscussion(srv.db, "testrepo", "First Thread", "body1", "alice")
createDiscussion(srv.db, "testrepo", "Second Thread", "body2", "bob")
w := get(t, srv, "/testrepo/discussions")
assertCode(t, w, 200)
assertContains(t, w, "First Thread")
assertContains(t, w, "Second Thread")
assertContains(t, w, "alice")
assertContains(t, w, "bob")
assertNotContains(t, w, "No discussions yet")
}
func TestDiscussions_ListShowsReplyCount(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "With Replies", "", "alice")
createReply(srv.db, int(id), "reply 1", "bob")
createReply(srv.db, int(id), "reply 2", "carol")
w := get(t, srv, "/testrepo/discussions")
assertCode(t, w, 200)
assertContains(t, w, ">2<")
}
func TestDiscussions_ListOnlyShowsCurrentRepo(t *testing.T) {
srv := testServer(t)
createDiscussion(srv.db, "testrepo", "Mine", "", "alice")
createDiscussion(srv.db, "other", "Not Mine", "", "bob")
w := get(t, srv, "/testrepo/discussions")
assertCode(t, w, 200)
assertContains(t, w, "Mine")
assertNotContains(t, w, "Not Mine")
}
func TestDiscussions_ListShowsNewButtonWhenLoggedIn(t *testing.T) {
srv := testServer(t)
w := getWithCookie(t, srv, "/testrepo/discussions", "alice")
assertCode(t, w, 200)
assertContains(t, w, "New discussion")
}
func TestDiscussions_ListHidesNewButtonWhenLoggedOut(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/discussions")
assertCode(t, w, 200)
assertNotContains(t, w, "New discussion")
}
func TestDiscussions_NewRedirectsWhenNotLoggedIn(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/discussions/new")
assertRedirect(t, w, 303, "login")
}
func TestDiscussions_NewPageWhenLoggedIn(t *testing.T) {
srv := testServer(t)
w := getWithCookie(t, srv, "/testrepo/discussions/new", "alice")
assertCode(t, w, 200)
assertContains(t, w, "New discussion")
assertContains(t, w, "Title")
assertContains(t, w, "Create discussion")
}
func TestDiscussions_CreateSuccess(t *testing.T) {
srv := testServer(t)
form := url.Values{"title": {"My Discussion"}, "body": {"Discussion body."}}
w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice")
assertRedirect(t, w, 303, "/testrepo/discussions/")
// Verify persisted.
list := listDiscussions(srv.db, "testrepo")
if len(list) != 1 {
t.Fatalf("expected 1, got %d", len(list))
}
if list[0].Title != "My Discussion" {
t.Errorf("title: got %q", list[0].Title)
}
if list[0].Author != "alice" {
t.Errorf("author: got %q", list[0].Author)
}
}
func TestDiscussions_CreateEmptyTitle(t *testing.T) {
srv := testServer(t)
form := url.Values{"title": {""}, "body": {"some body"}}
w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice")
assertCode(t, w, 200)
assertContains(t, w, "Title is required")
}
func TestDiscussions_CreateNotLoggedIn(t *testing.T) {
srv := testServer(t)
form := url.Values{"title": {"Title"}, "body": {"body"}}
w := postForm(t, srv, "/testrepo/discussions/new", form)
assertRedirect(t, w, 303, "login")
}
func TestDiscussions_ViewDiscussion(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "View Me", "Look at this body", "alice")
createReply(srv.db, int(id), "A thoughtful reply", "bob")
w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
assertCode(t, w, 200)
assertContains(t, w, "View Me")
assertContains(t, w, "Look at this body")
assertContains(t, w, "alice")
assertContains(t, w, "A thoughtful reply")
assertContains(t, w, "bob")
assertContains(t, w, "Sign in")
}
func TestDiscussions_ViewDiscussionLoggedIn(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "Logged In View", "", "alice")
w := getWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), "bob")
assertCode(t, w, 200)
assertContains(t, w, "Reply as")
assertContains(t, w, "bob")
assertContains(t, w, "Post reply")
}
func TestDiscussions_ViewNotFound(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/discussions/99999")
assertCode(t, w, 404)
assertContains(t, w, "Discussion not found")
}
func TestDiscussions_ViewInvalidID(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/testrepo/discussions/abc")
assertCode(t, w, 404)
assertContains(t, w, "Discussion not found")
}
func TestDiscussions_ViewWrongRepo(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "otherrepo", "Wrong Repo", "", "alice")
w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
assertCode(t, w, 404)
assertContains(t, w, "Discussion not found")
}
func TestDiscussions_Reply(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
form := url.Values{"body": {"My reply text"}}
w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob")
assertRedirect(t, w, 303, fmt.Sprintf("/testrepo/discussions/%d#reply-", id))
replies := getReplies(srv.db, int(id))
if len(replies) != 1 {
t.Fatalf("expected 1 reply, got %d", len(replies))
}
if replies[0].Body != "My reply text" {
t.Errorf("body: got %q", replies[0].Body)
}
if replies[0].Author != "bob" {
t.Errorf("author: got %q", replies[0].Author)
}
}
func TestDiscussions_ReplyEmptyBody(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
form := url.Values{"body": {""}}
w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob")
assertCode(t, w, 200)
assertContains(t, w, "Reply cannot be empty")
}
func TestDiscussions_ReplyNotLoggedIn(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
form := url.Values{"body": {"attempt"}}
w := postForm(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form)
assertCode(t, w, 403)
assertContains(t, w, "must be signed in")
}
// ===================================================================
// Login / Logout
// ===================================================================
func TestLogin_Page(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/login")
assertCode(t, w, 200)
assertContains(t, w, "Sign in with")
assertContains(t, w, "Bluesky")
assertContains(t, w, "handle")
assertContains(t, w, "app_password")
}
func TestLogin_PageWithReturn(t *testing.T) {
srv := testServer(t)
w := get(t, srv, "/login?return=/testrepo/discussions")
assertCode(t, w, 200)
assertContains(t, w, "/testrepo/discussions")
}
func TestLogin_PostMissingFields(t *testing.T) {
srv := testServer(t)
form := url.Values{"handle": {""}, "app_password": {""}}
w := postForm(t, srv, "/login", form)
assertCode(t, w, 200)
assertContains(t, w, "Handle and app password are required")
}
func TestLogin_PostMissingPassword(t *testing.T) {
srv := testServer(t)
form := url.Values{"handle": {"alice.bsky.social"}, "app_password": {""}}
w := postForm(t, srv, "/login", form)
assertCode(t, w, 200)
assertContains(t, w, "Handle and app password are required")
}
func TestLogout(t *testing.T) {
srv := testServer(t)
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("alice")})
w := httptest.NewRecorder()
srv.route(w, req)
assertRedirect(t, w, 303, "/")
found := false
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName && c.MaxAge < 0 {
found = true
}
}
if !found {
t.Error("expected session cookie to be cleared")
}
}
func TestLogout_WithReturn(t *testing.T) {
srv := testServer(t)
req := httptest.NewRequest(http.MethodGet, "/logout?return=/testrepo/", nil)
w := httptest.NewRecorder()
srv.route(w, req)
assertRedirect(t, w, 303, "/testrepo/")
}
func TestLogout_DefaultReturn(t *testing.T) {
srv := testServer(t)
req := httptest.NewRequest(http.MethodGet, "/logout", nil)
w := httptest.NewRecorder()
srv.route(w, req)
assertRedirect(t, w, 303, "/")
}
// ===================================================================
// isGitHTTPRequest
// ===================================================================
func TestIsGitHTTPRequest(t *testing.T) {
tests := []struct {
path string
query string
want bool
}{
{"/repo/info/refs", "service=git-upload-pack", true},
{"/repo/info/refs", "service=git-receive-pack", true},
{"/repo/info/refs", "", false},
{"/repo/git-upload-pack", "", true},
{"/repo/git-receive-pack", "", true},
{"/repo/HEAD", "", true},
{"/repo/objects/pack/pack-abc.pack", "", true},
{"/repo/refs", "", false},
{"/repo/", "", false},
{"/repo/tree/main", "", false},
}
for _, tt := range tests {
u := tt.path
if tt.query != "" {
u += "?" + tt.query
}
req := httptest.NewRequest(http.MethodGet, u, nil)
got := isGitHTTPRequest(req)
if got != tt.want {
t.Errorf("isGitHTTPRequest(%q, %q) = %v, want %v", tt.path, tt.query, got, tt.want)
}
}
}
// ===================================================================
// Session sign / verify
// ===================================================================
func TestSession_SignAndVerify(t *testing.T) {
signed := signSession("alice")
handle, ok := verifySession(signed)
if !ok {
t.Fatal("expected valid session")
}
if handle != "alice" {
t.Errorf("expected alice, got %q", handle)
}
}
func TestSession_Garbage(t *testing.T) {
if _, ok := verifySession("garbage"); ok {
t.Error("expected invalid")
}
}
func TestSession_Tampered(t *testing.T) {
signed := signSession("alice")
tampered := signed[:len(signed)-2] + "ff"
if _, ok := verifySession(tampered); ok {
t.Error("expected tampered session to be invalid")
}
}
func TestSession_TwoParts(t *testing.T) {
if _, ok := verifySession("a|b"); ok {
t.Error("expected invalid with only 2 parts")
}
}
func TestSession_ExpiredTimestamp(t *testing.T) {
if _, ok := verifySession("alice|1000000000|fakesig"); ok {
t.Error("expected expired session to be invalid")
}
}
func TestSession_BadExpiry(t *testing.T) {
if _, ok := verifySession("alice|notanumber|fakesig"); ok {
t.Error("expected invalid with bad expiry")
}
}
func TestGetSessionHandle_NoCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
if h := getSessionHandle(req); h != "" {
t.Errorf("expected empty, got %q", h)
}
}
func TestGetSessionHandle_ValidCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("bob")})
if h := getSessionHandle(req); h != "bob" {
t.Errorf("expected bob, got %q", h)
}
}
func TestGetSessionHandle_InvalidCookie(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "bad"})
if h := getSessionHandle(req); h != "" {
t.Errorf("expected empty, got %q", h)
}
}
func TestSetSessionCookie(t *testing.T) {
w := httptest.NewRecorder()
setSessionCookie(w, "carol")
found := false
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName {
found = true
if !c.HttpOnly {
t.Error("expected HttpOnly")
}
if c.MaxAge <= 0 {
t.Error("expected positive MaxAge")
}
h, ok := verifySession(c.Value)
if !ok || h != "carol" {
t.Error("cookie value doesn't verify")
}
}
}
if !found {
t.Error("session cookie not set")
}
}
func TestClearSessionCookie(t *testing.T) {
w := httptest.NewRecorder()
clearSessionCookie(w)
found := false
for _, c := range w.Result().Cookies() {
if c.Name == sessionCookieName {
found = true
if c.MaxAge >= 0 {
t.Error("expected negative MaxAge")
}
}
}
if !found {
t.Error("session cookie not set for clearing")
}
}
// ===================================================================
// Database CRUD
// ===================================================================
func TestDB_DiscussionCRUD(t *testing.T) {
srv := testServer(t)
id, err := createDiscussion(srv.db, "testrepo", "DB Title", "DB Body", "author1")
if err != nil {
t.Fatalf("createDiscussion: %v", err)
}
if id == 0 {
t.Fatal("expected non-zero ID")
}
d, err := getDiscussion(srv.db, int(id))
if err != nil {
t.Fatalf("getDiscussion: %v", err)
}
if d.Title != "DB Title" {
t.Errorf("title: %q", d.Title)
}
if d.Body != "DB Body" {
t.Errorf("body: %q", d.Body)
}
if d.Author != "author1" {
t.Errorf("author: %q", d.Author)
}
if d.Repo != "testrepo" {
t.Errorf("repo: %q", d.Repo)
}
}
func TestDB_Replies(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "T", "", "a")
createReply(srv.db, int(id), "Reply one", "user1")
createReply(srv.db, int(id), "Reply two", "user2")
replies := getReplies(srv.db, int(id))
if len(replies) != 2 {
t.Fatalf("expected 2, got %d", len(replies))
}
if replies[0].Body != "Reply one" || replies[0].Author != "user1" {
t.Errorf("reply 0: %+v", replies[0])
}
if replies[1].Body != "Reply two" || replies[1].Author != "user2" {
t.Errorf("reply 1: %+v", replies[1])
}
}
func TestDB_RepliesEmpty(t *testing.T) {
srv := testServer(t)
if replies := getReplies(srv.db, 99999); len(replies) != 0 {
t.Errorf("expected 0, got %d", len(replies))
}
}
func TestDB_GetDiscussionNotFound(t *testing.T) {
srv := testServer(t)
if _, err := getDiscussion(srv.db, 99999); err == nil {
t.Error("expected error")
}
}
func TestDB_ReplyCount(t *testing.T) {
srv := testServer(t)
id, _ := createDiscussion(srv.db, "testrepo", "Count", "", "a")
createReply(srv.db, int(id), "r1", "b")
createReply(srv.db, int(id), "r2", "c")
createReply(srv.db, int(id), "r3", "d")
list := listDiscussions(srv.db, "testrepo")
if len(list) != 1 {
t.Fatalf("expected 1, got %d", len(list))
}
if list[0].ReplyCount != 3 {
t.Errorf("expected 3 replies, got %d", list[0].ReplyCount)
}
}
func TestDB_AvatarUpsert(t *testing.T) {
srv := testServer(t)
upsertAvatar(srv.db, "alice", "https://example.com/v1.png")
if u := getAvatar(srv.db, "alice"); u != "https://example.com/v1.png" {
t.Errorf("got %q", u)
}
upsertAvatar(srv.db, "alice", "https://example.com/v2.png")
if u := getAvatar(srv.db, "alice"); u != "https://example.com/v2.png" {
t.Errorf("got %q", u)
}
}
func TestDB_AvatarNotFound(t *testing.T) {
srv := testServer(t)
if u := getAvatar(srv.db, "nobody"); u != "" {
t.Errorf("expected empty, got %q", u)
}
}
func TestDB_AvatarShownInDiscussion(t *testing.T) {
srv := testServer(t)
upsertAvatar(srv.db, "alice", "https://example.com/alice-av.png")
id, _ := createDiscussion(srv.db, "testrepo", "Avatar Test", "body", "alice")
w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
assertCode(t, w, 200)
assertContains(t, w, "https://example.com/alice-av.png")
}
// ===================================================================
// scanRepositories
// ===================================================================
func TestScanRepositories(t *testing.T) {
srv := testServer(t)
repos, err := scanRepositories(srv.scanPath, false)
if err != nil {
t.Fatalf("scanRepositories: %v", err)
}
if len(repos) != 1 {
t.Fatalf("expected 1, got %d", len(repos))
}
repo := repos["testrepo"]
if repo == nil {
t.Fatal("expected 'testrepo'")
}
if repo.Description != "A test repository" {
t.Errorf("description: %q", repo.Description)
}
if repo.Owner != "tester" {
t.Errorf("owner: %q", repo.Owner)
}
if repo.LastUpdated.IsZero() {
t.Error("expected non-zero LastUpdated")
}
}
func TestScanRepositories_SkipsPrivate(t *testing.T) {
tmpDir := t.TempDir()
gitRun(t, "", "init", "--bare", filepath.Join(tmpDir, "private.git"))
repos, _ := scanRepositories(tmpDir, false)
if len(repos) != 0 {
t.Errorf("expected 0, got %d", len(repos))
}
}
func TestScanRepositories_SkipsHiddenDirs(t *testing.T) {
tmpDir := t.TempDir()
repoDir := filepath.Join(tmpDir, ".hidden.git")
gitRun(t, "", "init", "--bare", repoDir)
os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
repos, _ := scanRepositories(tmpDir, false)
if len(repos) != 0 {
t.Errorf("expected 0, got %d", len(repos))
}
}
func TestScanRepositories_SkipsFiles(t *testing.T) {
tmpDir := t.TempDir()
os.WriteFile(filepath.Join(tmpDir, "not-a-dir"), []byte("x"), 0644)
repos, _ := scanRepositories(tmpDir, false)
if len(repos) != 0 {
t.Errorf("expected 0, got %d", len(repos))
}
}
func TestScanRepositories_NonBare(t *testing.T) {
tmpDir := t.TempDir()
workDir := filepath.Join(tmpDir, "myrepo")
gitRun(t, "", "init", workDir)
os.WriteFile(filepath.Join(workDir, ".git", "public"), nil, 0644)
repos, _ := scanRepositories(tmpDir, false)
if len(repos) != 0 {
t.Errorf("expected 0 without flag, got %d", len(repos))
}
repos, _ = scanRepositories(tmpDir, true)
if len(repos) != 1 {
t.Fatalf("expected 1 with flag, got %d", len(repos))
}
}
func TestScanRepositories_StripsGitSuffix(t *testing.T) {
srv := testServer(t)
repos, _ := scanRepositories(srv.scanPath, false)
if _, ok := repos["testrepo"]; !ok {
t.Error("expected .git suffix stripped")
}
}
func TestScanRepositories_DefaultDescription(t *testing.T) {
tmpDir := t.TempDir()
repoDir := filepath.Join(tmpDir, "repo.git")
gitRun(t, "", "init", "--bare", repoDir)
os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
repos, _ := scanRepositories(tmpDir, false)
if r, ok := repos["repo"]; ok && r.Description != "" {
t.Errorf("default description should be empty, got %q", r.Description)
}
}
// ===================================================================
// isBareRepo / isWorkTreeRepo
// ===================================================================
func TestIsBareRepo(t *testing.T) {
tmpDir := t.TempDir()
repoDir := filepath.Join(tmpDir, "bare.git")
gitRun(t, "", "init", "--bare", repoDir)
if !isBareRepo(repoDir) {
t.Error("expected bare repo")
}
if isBareRepo(tmpDir) {
t.Error("tmpDir is not bare")
}
}
func TestIsWorkTreeRepo(t *testing.T) {
tmpDir := t.TempDir()
workDir := filepath.Join(tmpDir, "work")
gitRun(t, "", "init", workDir)
if !isWorkTreeRepo(workDir) {
t.Error("expected work tree repo")
}
if isWorkTreeRepo(tmpDir) {
t.Error("tmpDir is not work tree")
}
}
// ===================================================================
// Template FuncMap
// ===================================================================
func TestFuncMap_ShortHash(t *testing.T) {
fn := funcMap["shortHash"].(func(string) string)
if got := fn("abcdef1234567890"); got != "abcdef12" {
t.Errorf("got %q", got)
}
if got := fn("abc"); got != "abc" {
t.Errorf("got %q", got)
}
if got := fn(""); got != "" {
t.Errorf("got %q", got)
}
}
func TestFuncMap_StatusLabel(t *testing.T) {
fn := funcMap["statusLabel"].(func(string) string)
cases := map[string]string{"A": "added", "D": "deleted", "M": "modified", "R": "renamed", "X": "X"}
for in, want := range cases {
if got := fn(in); got != want {
t.Errorf("statusLabel(%q) = %q, want %q", in, got, want)
}
}
}
func TestFuncMap_FormatSize(t *testing.T) {
fn := funcMap["formatSize"].(func(int64) string)
if got := fn(500); got != "500 B" {
t.Errorf("got %q", got)
}
if got := fn(2048); !strings.Contains(got, "KiB") {
t.Errorf("got %q", got)
}
if got := fn(2 * 1024 * 1024); !strings.Contains(got, "MiB") {
t.Errorf("got %q", got)
}
}
func TestFuncMap_DiffFileName(t *testing.T) {
fn := funcMap["diffFileName"].(func(DiffFile) string)
if got := fn(DiffFile{OldName: "old.go", NewName: "new.go"}); got != "new.go" {
t.Errorf("got %q", got)
}
if got := fn(DiffFile{OldName: "old.go", NewName: ""}); got != "old.go" {
t.Errorf("got %q", got)
}
}
func TestFuncMap_ParentPath(t *testing.T) {
fn := funcMap["parentPath"].(func(string) string)
if got := fn("a/b/c"); got != "a/b" {
t.Errorf("got %q", got)
}
if got := fn("file.go"); got != "" {
t.Errorf("got %q", got)
}
}
func TestFuncMap_Indent(t *testing.T) {
fn := funcMap["indent"].(func(int) string)
if got := fn(0); got != "" {
t.Errorf("got %q", got)
}
if got := fn(2); !strings.Contains(got, "padding-left") {
t.Errorf("got %q", got)
}
}
func TestFuncMap_LangClass(t *testing.T) {
fn := funcMap["langClass"].(func(string) string)
if got := fn("file.rad"); got != "language-radiance" {
t.Errorf("got %q", got)
}
if got := fn("file.ril"); got != "language-ril" {
t.Errorf("got %q", got)
}
if got := fn("file.go"); got != "" {
t.Errorf("got %q", got)
}
}
func TestFuncMap_Add(t *testing.T) {
fn := funcMap["add"].(func(int, int) int)
if got := fn(3, 4); got != 7 {
t.Errorf("got %d", got)
}
}
func TestFuncMap_Autolink(t *testing.T) {
fn := funcMap["autolink"].(func(string) template.HTML)
tests := []struct {
input string
contains string
}{
{"hello https://example.com world", `https://example.com`},
{"no links here", "no links here"},
{"http://foo.com.", `http://foo.com`},
{"