Add tests

1f53abd71d337e228aae4e482fb9886c9e1db985
Alexis Sellier committed ago 1 parent 2de8c32e
handler_test.go added +1789 -0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"html/template"
6 +
	"net/http"
7 +
	"net/http/httptest"
8 +
	"net/url"
9 +
	"os"
10 +
	"os/exec"
11 +
	"path/filepath"
12 +
	"strings"
13 +
	"testing"
14 +
)
15 +
16 +
// --- Test helpers ---
17 +
18 +
// testServer creates a server backed by a real git repository with sample content.
19 +
func testServer(t *testing.T) *server {
20 +
	t.Helper()
21 +
22 +
	tmpDir := t.TempDir()
23 +
24 +
	// Create a bare repo.
25 +
	repoDir := filepath.Join(tmpDir, "testrepo.git")
26 +
	gitRun(t, "", "init", "--bare", repoDir)
27 +
28 +
	// Mark it public and set metadata.
29 +
	os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
30 +
	os.WriteFile(filepath.Join(repoDir, "description"), []byte("A test repository"), 0644)
31 +
	os.WriteFile(filepath.Join(repoDir, "owner"), []byte("tester"), 0644)
32 +
33 +
	// Clone, add content, push.
34 +
	workDir := filepath.Join(tmpDir, "work")
35 +
	gitRun(t, "", "clone", repoDir, workDir)
36 +
	gitRun(t, workDir, "config", "user.email", "test@example.com")
37 +
	gitRun(t, workDir, "config", "user.name", "Test User")
38 +
39 +
	// Create files in initial commit.
40 +
	os.WriteFile(filepath.Join(workDir, "README.md"), []byte("# Test Repo\n\nHello world.\n"), 0644)
41 +
	os.WriteFile(filepath.Join(workDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0644)
42 +
	os.MkdirAll(filepath.Join(workDir, "pkg"), 0755)
43 +
	os.WriteFile(filepath.Join(workDir, "pkg", "lib.go"), []byte("package pkg\n"), 0644)
44 +
	gitRun(t, workDir, "add", ".")
45 +
	gitRun(t, workDir, "commit", "-m", "Initial commit")
46 +
	gitRun(t, workDir, "push", "origin", "HEAD")
47 +
48 +
	// Second commit.
49 +
	os.WriteFile(filepath.Join(workDir, "extra.txt"), []byte("extra content\n"), 0644)
50 +
	gitRun(t, workDir, "add", ".")
51 +
	gitRun(t, workDir, "commit", "-m", "Add extra file")
52 +
	gitRun(t, workDir, "push", "origin", "HEAD")
53 +
54 +
	// Tag.
55 +
	gitRun(t, workDir, "tag", "-a", "v1.0", "-m", "Release v1.0")
56 +
	gitRun(t, workDir, "push", "origin", "v1.0")
57 +
58 +
	// Feature branch with slash in name.
59 +
	gitRun(t, workDir, "checkout", "-b", "feature/test")
60 +
	os.WriteFile(filepath.Join(workDir, "feature.txt"), []byte("feature content\n"), 0644)
61 +
	gitRun(t, workDir, "add", ".")
62 +
	gitRun(t, workDir, "commit", "-m", "Add feature")
63 +
	gitRun(t, workDir, "push", "origin", "feature/test")
64 +
65 +
	git := newGitCLIBackend(repoDir)
66 +
	defaultBranch := git.getDefaultBranch()
67 +
	commit, _ := git.getCommit(defaultBranch)
68 +
69 +
	repo := &RepoInfo{
70 +
		Name:        "testrepo",
71 +
		Path:        repoDir,
72 +
		GitDir:      repoDir,
73 +
		Description: "A test repository",
74 +
		Owner:       "tester",
75 +
		Git:         git,
76 +
	}
77 +
	if commit != nil {
78 +
		repo.LastUpdated = commit.AuthorDate
79 +
	}
80 +
81 +
	tmpl, err := loadTemplates()
82 +
	if err != nil {
83 +
		t.Fatalf("loadTemplates: %v", err)
84 +
	}
85 +
86 +
	db, err := openDB(filepath.Join(tmpDir, "test.db"))
87 +
	if err != nil {
88 +
		t.Fatalf("openDB: %v", err)
89 +
	}
90 +
	t.Cleanup(func() { db.Close() })
91 +
92 +
	return &server{
93 +
		repos:       map[string]*RepoInfo{"testrepo": repo},
94 +
		sorted:      []string{"testrepo"},
95 +
		tmpl:        tmpl,
96 +
		db:          db,
97 +
		title:       "Test Forge",
98 +
		description: "test site description",
99 +
		baseURL:     "",
100 +
		scanPath:    tmpDir,
101 +
	}
102 +
}
103 +
104 +
func gitRun(t *testing.T, dir string, args ...string) {
105 +
	t.Helper()
106 +
	cmd := exec.Command("git", args...)
107 +
	if dir != "" {
108 +
		cmd.Dir = dir
109 +
	}
110 +
	cmd.Env = append(os.Environ(),
111 +
		"GIT_CONFIG_GLOBAL=/dev/null",
112 +
		"GIT_CONFIG_SYSTEM=/dev/null",
113 +
	)
114 +
	out, err := cmd.CombinedOutput()
115 +
	if err != nil {
116 +
		t.Fatalf("git %v (dir=%s): %v\n%s", args, dir, err, out)
117 +
	}
118 +
}
119 +
120 +
// get sends a GET through the route dispatcher.
121 +
func get(t *testing.T, srv *server, path string) *httptest.ResponseRecorder {
122 +
	t.Helper()
123 +
	req := httptest.NewRequest(http.MethodGet, path, nil)
124 +
	w := httptest.NewRecorder()
125 +
	srv.route(w, req)
126 +
	return w
127 +
}
128 +
129 +
// getWithCookie sends a GET with a session cookie.
130 +
func getWithCookie(t *testing.T, srv *server, path, handle string) *httptest.ResponseRecorder {
131 +
	t.Helper()
132 +
	req := httptest.NewRequest(http.MethodGet, path, nil)
133 +
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)})
134 +
	w := httptest.NewRecorder()
135 +
	srv.route(w, req)
136 +
	return w
137 +
}
138 +
139 +
// postForm sends a POST with form data through the route dispatcher.
140 +
func postForm(t *testing.T, srv *server, path string, form url.Values) *httptest.ResponseRecorder {
141 +
	t.Helper()
142 +
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
143 +
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
144 +
	w := httptest.NewRecorder()
145 +
	srv.route(w, req)
146 +
	return w
147 +
}
148 +
149 +
// postFormWithCookie sends a POST with form data and a session cookie.
150 +
func postFormWithCookie(t *testing.T, srv *server, path string, form url.Values, handle string) *httptest.ResponseRecorder {
151 +
	t.Helper()
152 +
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
153 +
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
154 +
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)})
155 +
	w := httptest.NewRecorder()
156 +
	srv.route(w, req)
157 +
	return w
158 +
}
159 +
160 +
// getMux sends a GET through the full mux (including static asset routes).
161 +
func getMux(t *testing.T, srv *server, path string) *httptest.ResponseRecorder {
162 +
	t.Helper()
163 +
	mux := http.NewServeMux()
164 +
	mux.HandleFunc("/style.css", srv.serveCSS)
165 +
	mux.HandleFunc("/radiant.svg", srv.serveLogo)
166 +
	mux.HandleFunc("/js/", srv.serveJS)
167 +
	mux.HandleFunc("/fonts/", srv.serveFont)
168 +
	mux.HandleFunc("/", srv.route)
169 +
	req := httptest.NewRequest(http.MethodGet, path, nil)
170 +
	w := httptest.NewRecorder()
171 +
	mux.ServeHTTP(w, req)
172 +
	return w
173 +
}
174 +
175 +
// defaultRef returns the default branch name for the test repo.
176 +
func defaultRef(t *testing.T, srv *server) string {
177 +
	t.Helper()
178 +
	return srv.repos["testrepo"].Git.getDefaultBranch()
179 +
}
180 +
181 +
// latestHash returns the latest commit hash on the default branch.
182 +
func latestHash(t *testing.T, srv *server) string {
183 +
	t.Helper()
184 +
	git := srv.repos["testrepo"].Git
185 +
	h, err := git.resolveRef(git.getDefaultBranch())
186 +
	if err != nil {
187 +
		t.Fatalf("resolveRef: %v", err)
188 +
	}
189 +
	return h
190 +
}
191 +
192 +
// initialHash returns the hash of the "Initial commit".
193 +
func initialHash(t *testing.T, srv *server) string {
194 +
	t.Helper()
195 +
	git := srv.repos["testrepo"].Git
196 +
	h, _ := git.resolveRef(git.getDefaultBranch())
197 +
	commits, _, _ := git.getLog(h, 0, 50)
198 +
	for _, c := range commits {
199 +
		if c.Subject == "Initial commit" {
200 +
			return c.Hash
201 +
		}
202 +
	}
203 +
	t.Fatal("initial commit not found")
204 +
	return ""
205 +
}
206 +
207 +
func assertCode(t *testing.T, w *httptest.ResponseRecorder, want int) {
208 +
	t.Helper()
209 +
	if w.Code != want {
210 +
		n := w.Body.Len()
211 +
		if n > 500 {
212 +
			n = 500
213 +
		}
214 +
		t.Fatalf("expected status %d, got %d\nbody: %s", want, w.Code, w.Body.String()[:n])
215 +
	}
216 +
}
217 +
218 +
func assertContains(t *testing.T, w *httptest.ResponseRecorder, substr string) {
219 +
	t.Helper()
220 +
	if !strings.Contains(w.Body.String(), substr) {
221 +
		n := w.Body.Len()
222 +
		if n > 1000 {
223 +
			n = 1000
224 +
		}
225 +
		t.Errorf("expected body to contain %q\nbody: %s", substr, w.Body.String()[:n])
226 +
	}
227 +
}
228 +
229 +
func assertNotContains(t *testing.T, w *httptest.ResponseRecorder, substr string) {
230 +
	t.Helper()
231 +
	if strings.Contains(w.Body.String(), substr) {
232 +
		t.Errorf("expected body NOT to contain %q", substr)
233 +
	}
234 +
}
235 +
236 +
func assertHeader(t *testing.T, w *httptest.ResponseRecorder, key, wantSubstr string) {
237 +
	t.Helper()
238 +
	got := w.Header().Get(key)
239 +
	if !strings.Contains(got, wantSubstr) {
240 +
		t.Errorf("expected header %s to contain %q, got %q", key, wantSubstr, got)
241 +
	}
242 +
}
243 +
244 +
func assertRedirect(t *testing.T, w *httptest.ResponseRecorder, code int, locSubstr string) {
245 +
	t.Helper()
246 +
	assertCode(t, w, code)
247 +
	loc := w.Header().Get("Location")
248 +
	if !strings.Contains(loc, locSubstr) {
249 +
		t.Errorf("expected Location to contain %q, got %q", locSubstr, loc)
250 +
	}
251 +
}
252 +
253 +
// ===================================================================
254 +
// Static assets
255 +
// ===================================================================
256 +
257 +
func TestServeCSS(t *testing.T) {
258 +
	srv := testServer(t)
259 +
	w := getMux(t, srv, "/style.css")
260 +
	assertCode(t, w, 200)
261 +
	assertHeader(t, w, "Content-Type", "text/css")
262 +
	assertHeader(t, w, "Cache-Control", "public")
263 +
	if w.Body.Len() == 0 {
264 +
		t.Error("empty CSS body")
265 +
	}
266 +
}
267 +
268 +
func TestServeLogo(t *testing.T) {
269 +
	srv := testServer(t)
270 +
	w := getMux(t, srv, "/radiant.svg")
271 +
	assertCode(t, w, 200)
272 +
	assertHeader(t, w, "Content-Type", "image/svg+xml")
273 +
	assertHeader(t, w, "Cache-Control", "public")
274 +
	if w.Body.Len() == 0 {
275 +
		t.Error("empty logo body")
276 +
	}
277 +
}
278 +
279 +
func TestServeJS_Known(t *testing.T) {
280 +
	srv := testServer(t)
281 +
	for _, name := range []string{"hirad.js", "hiril.js"} {
282 +
		w := getMux(t, srv, "/js/"+name)
283 +
		assertCode(t, w, 200)
284 +
		assertHeader(t, w, "Content-Type", "javascript")
285 +
		assertHeader(t, w, "Cache-Control", "public")
286 +
		if w.Body.Len() == 0 {
287 +
			t.Errorf("empty body for %s", name)
288 +
		}
289 +
	}
290 +
}
291 +
292 +
func TestServeJS_Unknown(t *testing.T) {
293 +
	srv := testServer(t)
294 +
	w := getMux(t, srv, "/js/unknown.js")
295 +
	assertCode(t, w, 404)
296 +
}
297 +
298 +
func TestServeFont_Known(t *testing.T) {
299 +
	srv := testServer(t)
300 +
	w := getMux(t, srv, "/fonts/RethinkSans.ttf")
301 +
	assertCode(t, w, 200)
302 +
	assertHeader(t, w, "Content-Type", "font/ttf")
303 +
	assertHeader(t, w, "Cache-Control", "public")
304 +
	if w.Body.Len() == 0 {
305 +
		t.Error("empty font body")
306 +
	}
307 +
}
308 +
309 +
func TestServeFont_Unknown(t *testing.T) {
310 +
	srv := testServer(t)
311 +
	w := getMux(t, srv, "/fonts/nonexistent.ttf")
312 +
	assertCode(t, w, 404)
313 +
}
314 +
315 +
// ===================================================================
316 +
// Index page
317 +
// ===================================================================
318 +
319 +
func TestIndex(t *testing.T) {
320 +
	srv := testServer(t)
321 +
	w := get(t, srv, "/")
322 +
	assertCode(t, w, 200)
323 +
	assertContains(t, w, "testrepo")
324 +
	assertContains(t, w, "A test repository")
325 +
	assertContains(t, w, "Test Forge")
326 +
	assertContains(t, w, "test site description")
327 +
	// Footer
328 +
	assertContains(t, w, "Radiant Forge")
329 +
}
330 +
331 +
func TestIndex_Empty(t *testing.T) {
332 +
	srv := testServer(t)
333 +
	srv.repos = map[string]*RepoInfo{}
334 +
	srv.sorted = nil
335 +
	w := get(t, srv, "/")
336 +
	assertCode(t, w, 200)
337 +
	assertContains(t, w, "No repositories found")
338 +
}
339 +
340 +
func TestIndex_SignInLink(t *testing.T) {
341 +
	srv := testServer(t)
342 +
	w := get(t, srv, "/")
343 +
	assertCode(t, w, 200)
344 +
	assertContains(t, w, "Sign in")
345 +
}
346 +
347 +
func TestIndex_LoggedInShowsAvatar(t *testing.T) {
348 +
	srv := testServer(t)
349 +
	upsertAvatar(srv.db, "alice", "https://example.com/alice.png")
350 +
	w := getWithCookie(t, srv, "/", "alice")
351 +
	assertCode(t, w, 200)
352 +
	assertContains(t, w, "https://example.com/alice.png")
353 +
	assertContains(t, w, "Sign out")
354 +
}
355 +
356 +
// ===================================================================
357 +
// Repo summary (home)
358 +
// ===================================================================
359 +
360 +
func TestSummary(t *testing.T) {
361 +
	srv := testServer(t)
362 +
	w := get(t, srv, "/testrepo/")
363 +
	assertCode(t, w, 200)
364 +
	assertContains(t, w, "testrepo")
365 +
	assertContains(t, w, "A test repository")
366 +
	// Navigation tabs
367 +
	assertContains(t, w, "home")
368 +
	assertContains(t, w, "log")
369 +
	assertContains(t, w, "refs")
370 +
	assertContains(t, w, "discussions")
371 +
	// File tree
372 +
	assertContains(t, w, "README.md")
373 +
	assertContains(t, w, "main.go")
374 +
	assertContains(t, w, "pkg")
375 +
	assertContains(t, w, "extra.txt")
376 +
	// README content
377 +
	assertContains(t, w, "Hello world")
378 +
	// Last commit
379 +
	assertContains(t, w, "Add extra file")
380 +
	assertContains(t, w, "Test User")
381 +
	// Clone URLs
382 +
	assertContains(t, w, "testrepo.git")
383 +
	// No footer on repo pages
384 +
	assertNotContains(t, w, "Powered by")
385 +
}
386 +
387 +
func TestSummary_NoTrailingSlash(t *testing.T) {
388 +
	srv := testServer(t)
389 +
	w := get(t, srv, "/testrepo")
390 +
	assertCode(t, w, 200)
391 +
	assertContains(t, w, "testrepo")
392 +
}
393 +
394 +
func TestSummary_BranchSelector(t *testing.T) {
395 +
	srv := testServer(t)
396 +
	w := get(t, srv, "/testrepo/")
397 +
	assertCode(t, w, 200)
398 +
	assertContains(t, w, "feature/test")
399 +
}
400 +
401 +
func TestSummary_CloneHTTPS_XForwardedProto(t *testing.T) {
402 +
	srv := testServer(t)
403 +
	req := httptest.NewRequest(http.MethodGet, "/testrepo/", nil)
404 +
	req.Header.Set("X-Forwarded-Proto", "https")
405 +
	w := httptest.NewRecorder()
406 +
	srv.route(w, req)
407 +
	assertCode(t, w, 200)
408 +
	assertContains(t, w, "https://")
409 +
}
410 +
411 +
// ===================================================================
412 +
// Refs page
413 +
// ===================================================================
414 +
415 +
func TestRefs(t *testing.T) {
416 +
	srv := testServer(t)
417 +
	w := get(t, srv, "/testrepo/refs")
418 +
	assertCode(t, w, 200)
419 +
	assertContains(t, w, "feature/test")
420 +
	assertContains(t, w, "v1.0")
421 +
	assertContains(t, w, "Branch")
422 +
	assertContains(t, w, "Tag")
423 +
}
424 +
425 +
// ===================================================================
426 +
// Log page
427 +
// ===================================================================
428 +
429 +
func TestLog_Default(t *testing.T) {
430 +
	srv := testServer(t)
431 +
	w := get(t, srv, "/testrepo/log/")
432 +
	assertCode(t, w, 200)
433 +
	assertContains(t, w, "Initial commit")
434 +
	assertContains(t, w, "Add extra file")
435 +
	assertContains(t, w, "Test User")
436 +
	assertContains(t, w, "Hash")
437 +
	assertContains(t, w, "Subject")
438 +
}
439 +
440 +
func TestLog_SpecificRef(t *testing.T) {
441 +
	srv := testServer(t)
442 +
	w := get(t, srv, "/testrepo/log/feature/test")
443 +
	assertCode(t, w, 200)
444 +
	assertContains(t, w, "Add feature")
445 +
	assertContains(t, w, "Initial commit")
446 +
}
447 +
448 +
func TestLog_Pagination(t *testing.T) {
449 +
	srv := testServer(t)
450 +
	w := get(t, srv, "/testrepo/log/?page=0")
451 +
	assertCode(t, w, 200)
452 +
	assertContains(t, w, "Initial commit")
453 +
}
454 +
455 +
func TestLog_HighPage(t *testing.T) {
456 +
	srv := testServer(t)
457 +
	ref := defaultRef(t, srv)
458 +
	w := get(t, srv, "/testrepo/log/"+ref+"?page=999")
459 +
	assertCode(t, w, 200)
460 +
	assertContains(t, w, "No commits found")
461 +
}
462 +
463 +
func TestLog_NegativePage(t *testing.T) {
464 +
	srv := testServer(t)
465 +
	ref := defaultRef(t, srv)
466 +
	w := get(t, srv, "/testrepo/log/"+ref+"?page=-1")
467 +
	assertCode(t, w, 200)
468 +
	// Negative clamped to 0, should show commits.
469 +
	assertContains(t, w, "Initial commit")
470 +
}
471 +
472 +
func TestLog_InvalidRef(t *testing.T) {
473 +
	srv := testServer(t)
474 +
	w := get(t, srv, "/testrepo/log/nonexistent-ref")
475 +
	assertCode(t, w, 200)
476 +
	// Renders the empty log page.
477 +
}
478 +
479 +
func TestLog_BranchSelector(t *testing.T) {
480 +
	srv := testServer(t)
481 +
	w := get(t, srv, "/testrepo/log/")
482 +
	assertCode(t, w, 200)
483 +
	assertContains(t, w, "feature/test")
484 +
}
485 +
486 +
// ===================================================================
487 +
// Tree page
488 +
// ===================================================================
489 +
490 +
func TestTree_Root(t *testing.T) {
491 +
	srv := testServer(t)
492 +
	ref := defaultRef(t, srv)
493 +
	w := get(t, srv, "/testrepo/tree/"+ref)
494 +
	assertCode(t, w, 200)
495 +
	assertContains(t, w, "main.go")
496 +
	assertContains(t, w, "pkg")
497 +
	assertContains(t, w, "README.md")
498 +
}
499 +
500 +
func TestTree_Directory(t *testing.T) {
501 +
	srv := testServer(t)
502 +
	ref := defaultRef(t, srv)
503 +
	w := get(t, srv, "/testrepo/tree/"+ref+"/pkg")
504 +
	assertCode(t, w, 200)
505 +
	assertContains(t, w, "lib.go")
506 +
}
507 +
508 +
func TestTree_File(t *testing.T) {
509 +
	srv := testServer(t)
510 +
	ref := defaultRef(t, srv)
511 +
	w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
512 +
	assertCode(t, w, 200)
513 +
	assertContains(t, w, "package main")
514 +
	assertContains(t, w, "func main()")
515 +
	assertContains(t, w, "main.go")
516 +
	assertContains(t, w, "/raw/")
517 +
}
518 +
519 +
func TestTree_NestedFile(t *testing.T) {
520 +
	srv := testServer(t)
521 +
	ref := defaultRef(t, srv)
522 +
	w := get(t, srv, "/testrepo/tree/"+ref+"/pkg/lib.go")
523 +
	assertCode(t, w, 200)
524 +
	assertContains(t, w, "package pkg")
525 +
}
526 +
527 +
func TestTree_BranchWithSlash(t *testing.T) {
528 +
	srv := testServer(t)
529 +
	w := get(t, srv, "/testrepo/tree/feature/test")
530 +
	assertCode(t, w, 200)
531 +
	assertContains(t, w, "feature.txt")
532 +
}
533 +
534 +
func TestTree_BranchWithSlash_File(t *testing.T) {
535 +
	srv := testServer(t)
536 +
	w := get(t, srv, "/testrepo/tree/feature/test/feature.txt")
537 +
	assertCode(t, w, 200)
538 +
	assertContains(t, w, "feature content")
539 +
}
540 +
541 +
func TestTree_EmptyRest(t *testing.T) {
542 +
	srv := testServer(t)
543 +
	w := get(t, srv, "/testrepo/tree/")
544 +
	assertCode(t, w, 200)
545 +
	assertContains(t, w, "main.go")
546 +
}
547 +
548 +
func TestTree_InvalidRef(t *testing.T) {
549 +
	srv := testServer(t)
550 +
	w := get(t, srv, "/testrepo/tree/nonexistent-ref")
551 +
	assertCode(t, w, 404)
552 +
	assertContains(t, w, "Ref not found")
553 +
}
554 +
555 +
func TestTree_FileNotFound(t *testing.T) {
556 +
	srv := testServer(t)
557 +
	ref := defaultRef(t, srv)
558 +
	w := get(t, srv, "/testrepo/tree/"+ref+"/no-such-file.txt")
559 +
	assertCode(t, w, 404)
560 +
	assertContains(t, w, "File not found")
561 +
}
562 +
563 +
func TestTree_LineNumbers(t *testing.T) {
564 +
	srv := testServer(t)
565 +
	ref := defaultRef(t, srv)
566 +
	w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
567 +
	assertCode(t, w, 200)
568 +
	assertContains(t, w, `id="L1"`)
569 +
	assertContains(t, w, `href="#L1"`)
570 +
}
571 +
572 +
func TestTree_FileShowsSize(t *testing.T) {
573 +
	srv := testServer(t)
574 +
	ref := defaultRef(t, srv)
575 +
	w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
576 +
	assertCode(t, w, 200)
577 +
	// formatSize should produce "X B" for small files.
578 +
	assertContains(t, w, " B")
579 +
}
580 +
581 +
// ===================================================================
582 +
// Commit page
583 +
// ===================================================================
584 +
585 +
func TestCommit(t *testing.T) {
586 +
	srv := testServer(t)
587 +
	hash := latestHash(t, srv)
588 +
	w := get(t, srv, "/testrepo/commit/"+hash)
589 +
	assertCode(t, w, 200)
590 +
	assertContains(t, w, "Add extra file")
591 +
	assertContains(t, w, "Test User")
592 +
	assertContains(t, w, hash)
593 +
	assertContains(t, w, "extra.txt")
594 +
	assertContains(t, w, "parent")
595 +
}
596 +
597 +
func TestCommit_Initial(t *testing.T) {
598 +
	srv := testServer(t)
599 +
	hash := initialHash(t, srv)
600 +
	w := get(t, srv, "/testrepo/commit/"+hash)
601 +
	assertCode(t, w, 200)
602 +
	assertContains(t, w, "Initial commit")
603 +
	assertContains(t, w, "README.md")
604 +
	assertContains(t, w, "main.go")
605 +
}
606 +
607 +
func TestCommit_NotFound(t *testing.T) {
608 +
	srv := testServer(t)
609 +
	w := get(t, srv, "/testrepo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
610 +
	assertCode(t, w, 404)
611 +
	assertContains(t, w, "Commit not found")
612 +
}
613 +
614 +
func TestCommit_CommitHashInNav(t *testing.T) {
615 +
	srv := testServer(t)
616 +
	hash := latestHash(t, srv)
617 +
	w := get(t, srv, "/testrepo/commit/"+hash)
618 +
	assertCode(t, w, 200)
619 +
	assertContains(t, w, hash[:8])
620 +
}
621 +
622 +
func TestCommit_DiffStats(t *testing.T) {
623 +
	srv := testServer(t)
624 +
	hash := latestHash(t, srv)
625 +
	w := get(t, srv, "/testrepo/commit/"+hash)
626 +
	assertCode(t, w, 200)
627 +
	// Diff stats: +N -N
628 +
	assertContains(t, w, "added")
629 +
}
630 +
631 +
// ===================================================================
632 +
// Raw file download
633 +
// ===================================================================
634 +
635 +
func TestRaw(t *testing.T) {
636 +
	srv := testServer(t)
637 +
	ref := defaultRef(t, srv)
638 +
	w := get(t, srv, "/testrepo/raw/"+ref+"/main.go")
639 +
	assertCode(t, w, 200)
640 +
	assertHeader(t, w, "Content-Type", "text/plain")
641 +
	if !strings.Contains(w.Body.String(), "package main") {
642 +
		t.Error("expected raw file content")
643 +
	}
644 +
	cl := w.Header().Get("Content-Length")
645 +
	if cl == "" || cl == "0" {
646 +
		t.Error("expected non-zero Content-Length")
647 +
	}
648 +
}
649 +
650 +
func TestRaw_Markdown(t *testing.T) {
651 +
	srv := testServer(t)
652 +
	ref := defaultRef(t, srv)
653 +
	w := get(t, srv, "/testrepo/raw/"+ref+"/README.md")
654 +
	assertCode(t, w, 200)
655 +
	assertHeader(t, w, "Content-Type", "text/plain")
656 +
	assertContains(t, w, "# Test Repo")
657 +
}
658 +
659 +
func TestRaw_NoPath(t *testing.T) {
660 +
	srv := testServer(t)
661 +
	w := get(t, srv, "/testrepo/raw/")
662 +
	assertCode(t, w, 404)
663 +
	assertContains(t, w, "No path specified")
664 +
}
665 +
666 +
func TestRaw_RefOnly(t *testing.T) {
667 +
	srv := testServer(t)
668 +
	ref := defaultRef(t, srv)
669 +
	w := get(t, srv, "/testrepo/raw/"+ref)
670 +
	assertCode(t, w, 404)
671 +
	assertContains(t, w, "No file path")
672 +
}
673 +
674 +
func TestRaw_InvalidRef(t *testing.T) {
675 +
	srv := testServer(t)
676 +
	w := get(t, srv, "/testrepo/raw/nonexistent/file.txt")
677 +
	assertCode(t, w, 404)
678 +
	assertContains(t, w, "Ref not found")
679 +
}
680 +
681 +
func TestRaw_FileNotFound(t *testing.T) {
682 +
	srv := testServer(t)
683 +
	ref := defaultRef(t, srv)
684 +
	w := get(t, srv, "/testrepo/raw/"+ref+"/no-such-file.txt")
685 +
	assertCode(t, w, 404)
686 +
	assertContains(t, w, "File not found")
687 +
}
688 +
689 +
// ===================================================================
690 +
// Routing: repo not found, unknown action, .git suffix
691 +
// ===================================================================
692 +
693 +
func TestRoute_RepoNotFound(t *testing.T) {
694 +
	srv := testServer(t)
695 +
	w := get(t, srv, "/nonexistent/")
696 +
	assertCode(t, w, 404)
697 +
	assertContains(t, w, "Repository not found")
698 +
}
699 +
700 +
func TestRoute_UnknownAction(t *testing.T) {
701 +
	srv := testServer(t)
702 +
	w := get(t, srv, "/testrepo/foobar")
703 +
	assertCode(t, w, 404)
704 +
	assertContains(t, w, "Page not found")
705 +
}
706 +
707 +
func TestRoute_RepoNameDotGit(t *testing.T) {
708 +
	srv := testServer(t)
709 +
	w := get(t, srv, "/testrepo.git/refs")
710 +
	assertCode(t, w, 200)
711 +
	assertContains(t, w, "feature/test")
712 +
}
713 +
714 +
// ===================================================================
715 +
// Base URL prefix
716 +
// ===================================================================
717 +
718 +
func TestBaseURL_Index(t *testing.T) {
719 +
	srv := testServer(t)
720 +
	srv.baseURL = "/git"
721 +
	req := httptest.NewRequest(http.MethodGet, "/git/", nil)
722 +
	w := httptest.NewRecorder()
723 +
	srv.route(w, req)
724 +
	assertCode(t, w, 200)
725 +
	assertContains(t, w, "/git/testrepo/")
726 +
}
727 +
728 +
func TestBaseURL_Summary(t *testing.T) {
729 +
	srv := testServer(t)
730 +
	srv.baseURL = "/git"
731 +
	req := httptest.NewRequest(http.MethodGet, "/git/testrepo/", nil)
732 +
	w := httptest.NewRecorder()
733 +
	srv.route(w, req)
734 +
	assertCode(t, w, 200)
735 +
	assertContains(t, w, "/git/testrepo/")
736 +
}
737 +
738 +
func TestBaseURL_LinksInRepoPages(t *testing.T) {
739 +
	srv := testServer(t)
740 +
	srv.baseURL = "/code"
741 +
	req := httptest.NewRequest(http.MethodGet, "/code/testrepo/refs", nil)
742 +
	w := httptest.NewRecorder()
743 +
	srv.route(w, req)
744 +
	assertCode(t, w, 200)
745 +
	assertContains(t, w, "/code/testrepo/log/")
746 +
	assertContains(t, w, "/code/testrepo/commit/")
747 +
}
748 +
749 +
// ===================================================================
750 +
// Basic auth
751 +
// ===================================================================
752 +
753 +
func TestBasicAuth_NoCredentials(t *testing.T) {
754 +
	srv := testServer(t)
755 +
	srv.username = "admin"
756 +
	srv.password = "secret"
757 +
	mux := http.NewServeMux()
758 +
	mux.HandleFunc("/", srv.route)
759 +
	handler := srv.basicAuth(mux)
760 +
761 +
	req := httptest.NewRequest(http.MethodGet, "/", nil)
762 +
	w := httptest.NewRecorder()
763 +
	handler.ServeHTTP(w, req)
764 +
	assertCode(t, w, 401)
765 +
	assertHeader(t, w, "WWW-Authenticate", "Basic")
766 +
}
767 +
768 +
func TestBasicAuth_WrongCredentials(t *testing.T) {
769 +
	srv := testServer(t)
770 +
	srv.username = "admin"
771 +
	srv.password = "secret"
772 +
	mux := http.NewServeMux()
773 +
	mux.HandleFunc("/", srv.route)
774 +
	handler := srv.basicAuth(mux)
775 +
776 +
	req := httptest.NewRequest(http.MethodGet, "/", nil)
777 +
	req.SetBasicAuth("admin", "wrong")
778 +
	w := httptest.NewRecorder()
779 +
	handler.ServeHTTP(w, req)
780 +
	assertCode(t, w, 401)
781 +
}
782 +
783 +
func TestBasicAuth_CorrectCredentials(t *testing.T) {
784 +
	srv := testServer(t)
785 +
	srv.username = "admin"
786 +
	srv.password = "secret"
787 +
	mux := http.NewServeMux()
788 +
	mux.HandleFunc("/", srv.route)
789 +
	handler := srv.basicAuth(mux)
790 +
791 +
	req := httptest.NewRequest(http.MethodGet, "/", nil)
792 +
	req.SetBasicAuth("admin", "secret")
793 +
	w := httptest.NewRecorder()
794 +
	handler.ServeHTTP(w, req)
795 +
	assertCode(t, w, 200)
796 +
}
797 +
798 +
// ===================================================================
799 +
// Error page
800 +
// ===================================================================
801 +
802 +
func TestRenderError(t *testing.T) {
803 +
	srv := testServer(t)
804 +
	req := httptest.NewRequest(http.MethodGet, "/bad-path", nil)
805 +
	w := httptest.NewRecorder()
806 +
	srv.renderError(w, req, 404, "Something went wrong")
807 +
	assertCode(t, w, 404)
808 +
	assertContains(t, w, "Something went wrong")
809 +
	assertContains(t, w, "404")
810 +
	assertContains(t, w, "/bad-path")
811 +
	assertContains(t, w, "Back to index")
812 +
}
813 +
814 +
// ===================================================================
815 +
// Discussions
816 +
// ===================================================================
817 +
818 +
func TestDiscussions_ListEmpty(t *testing.T) {
819 +
	srv := testServer(t)
820 +
	w := get(t, srv, "/testrepo/discussions")
821 +
	assertCode(t, w, 200)
822 +
	assertContains(t, w, "No discussions yet")
823 +
	assertContains(t, w, "discussions")
824 +
}
825 +
826 +
func TestDiscussions_ListWithItems(t *testing.T) {
827 +
	srv := testServer(t)
828 +
	createDiscussion(srv.db, "testrepo", "First Thread", "body1", "alice")
829 +
	createDiscussion(srv.db, "testrepo", "Second Thread", "body2", "bob")
830 +
831 +
	w := get(t, srv, "/testrepo/discussions")
832 +
	assertCode(t, w, 200)
833 +
	assertContains(t, w, "First Thread")
834 +
	assertContains(t, w, "Second Thread")
835 +
	assertContains(t, w, "alice")
836 +
	assertContains(t, w, "bob")
837 +
	assertNotContains(t, w, "No discussions yet")
838 +
}
839 +
840 +
func TestDiscussions_ListShowsReplyCount(t *testing.T) {
841 +
	srv := testServer(t)
842 +
	id, _ := createDiscussion(srv.db, "testrepo", "With Replies", "", "alice")
843 +
	createReply(srv.db, int(id), "reply 1", "bob")
844 +
	createReply(srv.db, int(id), "reply 2", "carol")
845 +
846 +
	w := get(t, srv, "/testrepo/discussions")
847 +
	assertCode(t, w, 200)
848 +
	assertContains(t, w, ">2<")
849 +
}
850 +
851 +
func TestDiscussions_ListOnlyShowsCurrentRepo(t *testing.T) {
852 +
	srv := testServer(t)
853 +
	createDiscussion(srv.db, "testrepo", "Mine", "", "alice")
854 +
	createDiscussion(srv.db, "other", "Not Mine", "", "bob")
855 +
856 +
	w := get(t, srv, "/testrepo/discussions")
857 +
	assertCode(t, w, 200)
858 +
	assertContains(t, w, "Mine")
859 +
	assertNotContains(t, w, "Not Mine")
860 +
}
861 +
862 +
func TestDiscussions_ListShowsNewButtonWhenLoggedIn(t *testing.T) {
863 +
	srv := testServer(t)
864 +
	w := getWithCookie(t, srv, "/testrepo/discussions", "alice")
865 +
	assertCode(t, w, 200)
866 +
	assertContains(t, w, "New discussion")
867 +
}
868 +
869 +
func TestDiscussions_ListHidesNewButtonWhenLoggedOut(t *testing.T) {
870 +
	srv := testServer(t)
871 +
	w := get(t, srv, "/testrepo/discussions")
872 +
	assertCode(t, w, 200)
873 +
	assertNotContains(t, w, "New discussion")
874 +
}
875 +
876 +
func TestDiscussions_NewRedirectsWhenNotLoggedIn(t *testing.T) {
877 +
	srv := testServer(t)
878 +
	w := get(t, srv, "/testrepo/discussions/new")
879 +
	assertRedirect(t, w, 303, "login")
880 +
}
881 +
882 +
func TestDiscussions_NewPageWhenLoggedIn(t *testing.T) {
883 +
	srv := testServer(t)
884 +
	w := getWithCookie(t, srv, "/testrepo/discussions/new", "alice")
885 +
	assertCode(t, w, 200)
886 +
	assertContains(t, w, "New discussion")
887 +
	assertContains(t, w, "Title")
888 +
	assertContains(t, w, "Create discussion")
889 +
}
890 +
891 +
func TestDiscussions_CreateSuccess(t *testing.T) {
892 +
	srv := testServer(t)
893 +
	form := url.Values{"title": {"My Discussion"}, "body": {"Discussion body."}}
894 +
	w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice")
895 +
	assertRedirect(t, w, 303, "/testrepo/discussions/")
896 +
897 +
	// Verify persisted.
898 +
	list := listDiscussions(srv.db, "testrepo")
899 +
	if len(list) != 1 {
900 +
		t.Fatalf("expected 1, got %d", len(list))
901 +
	}
902 +
	if list[0].Title != "My Discussion" {
903 +
		t.Errorf("title: got %q", list[0].Title)
904 +
	}
905 +
	if list[0].Author != "alice" {
906 +
		t.Errorf("author: got %q", list[0].Author)
907 +
	}
908 +
}
909 +
910 +
func TestDiscussions_CreateEmptyTitle(t *testing.T) {
911 +
	srv := testServer(t)
912 +
	form := url.Values{"title": {""}, "body": {"some body"}}
913 +
	w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice")
914 +
	assertCode(t, w, 200)
915 +
	assertContains(t, w, "Title is required")
916 +
}
917 +
918 +
func TestDiscussions_CreateNotLoggedIn(t *testing.T) {
919 +
	srv := testServer(t)
920 +
	form := url.Values{"title": {"Title"}, "body": {"body"}}
921 +
	w := postForm(t, srv, "/testrepo/discussions/new", form)
922 +
	assertRedirect(t, w, 303, "login")
923 +
}
924 +
925 +
func TestDiscussions_ViewDiscussion(t *testing.T) {
926 +
	srv := testServer(t)
927 +
	id, _ := createDiscussion(srv.db, "testrepo", "View Me", "Look at this body", "alice")
928 +
	createReply(srv.db, int(id), "A thoughtful reply", "bob")
929 +
930 +
	w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
931 +
	assertCode(t, w, 200)
932 +
	assertContains(t, w, "View Me")
933 +
	assertContains(t, w, "Look at this body")
934 +
	assertContains(t, w, "alice")
935 +
	assertContains(t, w, "A thoughtful reply")
936 +
	assertContains(t, w, "bob")
937 +
	assertContains(t, w, "Sign in")
938 +
}
939 +
940 +
func TestDiscussions_ViewDiscussionLoggedIn(t *testing.T) {
941 +
	srv := testServer(t)
942 +
	id, _ := createDiscussion(srv.db, "testrepo", "Logged In View", "", "alice")
943 +
944 +
	w := getWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), "bob")
945 +
	assertCode(t, w, 200)
946 +
	assertContains(t, w, "Reply as")
947 +
	assertContains(t, w, "bob")
948 +
	assertContains(t, w, "Post reply")
949 +
}
950 +
951 +
func TestDiscussions_ViewNotFound(t *testing.T) {
952 +
	srv := testServer(t)
953 +
	w := get(t, srv, "/testrepo/discussions/99999")
954 +
	assertCode(t, w, 404)
955 +
	assertContains(t, w, "Discussion not found")
956 +
}
957 +
958 +
func TestDiscussions_ViewInvalidID(t *testing.T) {
959 +
	srv := testServer(t)
960 +
	w := get(t, srv, "/testrepo/discussions/abc")
961 +
	assertCode(t, w, 404)
962 +
	assertContains(t, w, "Discussion not found")
963 +
}
964 +
965 +
func TestDiscussions_ViewWrongRepo(t *testing.T) {
966 +
	srv := testServer(t)
967 +
	id, _ := createDiscussion(srv.db, "otherrepo", "Wrong Repo", "", "alice")
968 +
	w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
969 +
	assertCode(t, w, 404)
970 +
	assertContains(t, w, "Discussion not found")
971 +
}
972 +
973 +
func TestDiscussions_Reply(t *testing.T) {
974 +
	srv := testServer(t)
975 +
	id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
976 +
977 +
	form := url.Values{"body": {"My reply text"}}
978 +
	w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob")
979 +
	assertRedirect(t, w, 303, fmt.Sprintf("/testrepo/discussions/%d#reply-", id))
980 +
981 +
	replies := getReplies(srv.db, int(id))
982 +
	if len(replies) != 1 {
983 +
		t.Fatalf("expected 1 reply, got %d", len(replies))
984 +
	}
985 +
	if replies[0].Body != "My reply text" {
986 +
		t.Errorf("body: got %q", replies[0].Body)
987 +
	}
988 +
	if replies[0].Author != "bob" {
989 +
		t.Errorf("author: got %q", replies[0].Author)
990 +
	}
991 +
}
992 +
993 +
func TestDiscussions_ReplyEmptyBody(t *testing.T) {
994 +
	srv := testServer(t)
995 +
	id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
996 +
997 +
	form := url.Values{"body": {""}}
998 +
	w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob")
999 +
	assertCode(t, w, 200)
1000 +
	assertContains(t, w, "Reply cannot be empty")
1001 +
}
1002 +
1003 +
func TestDiscussions_ReplyNotLoggedIn(t *testing.T) {
1004 +
	srv := testServer(t)
1005 +
	id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
1006 +
1007 +
	form := url.Values{"body": {"attempt"}}
1008 +
	w := postForm(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form)
1009 +
	assertCode(t, w, 403)
1010 +
	assertContains(t, w, "must be signed in")
1011 +
}
1012 +
1013 +
// ===================================================================
1014 +
// Login / Logout
1015 +
// ===================================================================
1016 +
1017 +
func TestLogin_Page(t *testing.T) {
1018 +
	srv := testServer(t)
1019 +
	w := get(t, srv, "/login")
1020 +
	assertCode(t, w, 200)
1021 +
	assertContains(t, w, "Sign in with")
1022 +
	assertContains(t, w, "Bluesky")
1023 +
	assertContains(t, w, "handle")
1024 +
	assertContains(t, w, "app_password")
1025 +
}
1026 +
1027 +
func TestLogin_PageWithReturn(t *testing.T) {
1028 +
	srv := testServer(t)
1029 +
	w := get(t, srv, "/login?return=/testrepo/discussions")
1030 +
	assertCode(t, w, 200)
1031 +
	assertContains(t, w, "/testrepo/discussions")
1032 +
}
1033 +
1034 +
func TestLogin_PostMissingFields(t *testing.T) {
1035 +
	srv := testServer(t)
1036 +
	form := url.Values{"handle": {""}, "app_password": {""}}
1037 +
	w := postForm(t, srv, "/login", form)
1038 +
	assertCode(t, w, 200)
1039 +
	assertContains(t, w, "Handle and app password are required")
1040 +
}
1041 +
1042 +
func TestLogin_PostMissingPassword(t *testing.T) {
1043 +
	srv := testServer(t)
1044 +
	form := url.Values{"handle": {"alice.bsky.social"}, "app_password": {""}}
1045 +
	w := postForm(t, srv, "/login", form)
1046 +
	assertCode(t, w, 200)
1047 +
	assertContains(t, w, "Handle and app password are required")
1048 +
}
1049 +
1050 +
func TestLogout(t *testing.T) {
1051 +
	srv := testServer(t)
1052 +
	req := httptest.NewRequest(http.MethodGet, "/logout", nil)
1053 +
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("alice")})
1054 +
	w := httptest.NewRecorder()
1055 +
	srv.route(w, req)
1056 +
	assertRedirect(t, w, 303, "/")
1057 +
1058 +
	found := false
1059 +
	for _, c := range w.Result().Cookies() {
1060 +
		if c.Name == sessionCookieName && c.MaxAge < 0 {
1061 +
			found = true
1062 +
		}
1063 +
	}
1064 +
	if !found {
1065 +
		t.Error("expected session cookie to be cleared")
1066 +
	}
1067 +
}
1068 +
1069 +
func TestLogout_WithReturn(t *testing.T) {
1070 +
	srv := testServer(t)
1071 +
	req := httptest.NewRequest(http.MethodGet, "/logout?return=/testrepo/", nil)
1072 +
	w := httptest.NewRecorder()
1073 +
	srv.route(w, req)
1074 +
	assertRedirect(t, w, 303, "/testrepo/")
1075 +
}
1076 +
1077 +
func TestLogout_DefaultReturn(t *testing.T) {
1078 +
	srv := testServer(t)
1079 +
	req := httptest.NewRequest(http.MethodGet, "/logout", nil)
1080 +
	w := httptest.NewRecorder()
1081 +
	srv.route(w, req)
1082 +
	assertRedirect(t, w, 303, "/")
1083 +
}
1084 +
1085 +
// ===================================================================
1086 +
// isGitHTTPRequest
1087 +
// ===================================================================
1088 +
1089 +
func TestIsGitHTTPRequest(t *testing.T) {
1090 +
	tests := []struct {
1091 +
		path  string
1092 +
		query string
1093 +
		want  bool
1094 +
	}{
1095 +
		{"/repo/info/refs", "service=git-upload-pack", true},
1096 +
		{"/repo/info/refs", "service=git-receive-pack", true},
1097 +
		{"/repo/info/refs", "", false},
1098 +
		{"/repo/git-upload-pack", "", true},
1099 +
		{"/repo/git-receive-pack", "", true},
1100 +
		{"/repo/HEAD", "", true},
1101 +
		{"/repo/objects/pack/pack-abc.pack", "", true},
1102 +
		{"/repo/refs", "", false},
1103 +
		{"/repo/", "", false},
1104 +
		{"/repo/tree/main", "", false},
1105 +
	}
1106 +
	for _, tt := range tests {
1107 +
		u := tt.path
1108 +
		if tt.query != "" {
1109 +
			u += "?" + tt.query
1110 +
		}
1111 +
		req := httptest.NewRequest(http.MethodGet, u, nil)
1112 +
		got := isGitHTTPRequest(req)
1113 +
		if got != tt.want {
1114 +
			t.Errorf("isGitHTTPRequest(%q, %q) = %v, want %v", tt.path, tt.query, got, tt.want)
1115 +
		}
1116 +
	}
1117 +
}
1118 +
1119 +
// ===================================================================
1120 +
// Session sign / verify
1121 +
// ===================================================================
1122 +
1123 +
func TestSession_SignAndVerify(t *testing.T) {
1124 +
	signed := signSession("alice")
1125 +
	handle, ok := verifySession(signed)
1126 +
	if !ok {
1127 +
		t.Fatal("expected valid session")
1128 +
	}
1129 +
	if handle != "alice" {
1130 +
		t.Errorf("expected alice, got %q", handle)
1131 +
	}
1132 +
}
1133 +
1134 +
func TestSession_Garbage(t *testing.T) {
1135 +
	if _, ok := verifySession("garbage"); ok {
1136 +
		t.Error("expected invalid")
1137 +
	}
1138 +
}
1139 +
1140 +
func TestSession_Tampered(t *testing.T) {
1141 +
	signed := signSession("alice")
1142 +
	tampered := signed[:len(signed)-2] + "ff"
1143 +
	if _, ok := verifySession(tampered); ok {
1144 +
		t.Error("expected tampered session to be invalid")
1145 +
	}
1146 +
}
1147 +
1148 +
func TestSession_TwoParts(t *testing.T) {
1149 +
	if _, ok := verifySession("a|b"); ok {
1150 +
		t.Error("expected invalid with only 2 parts")
1151 +
	}
1152 +
}
1153 +
1154 +
func TestSession_ExpiredTimestamp(t *testing.T) {
1155 +
	if _, ok := verifySession("alice|1000000000|fakesig"); ok {
1156 +
		t.Error("expected expired session to be invalid")
1157 +
	}
1158 +
}
1159 +
1160 +
func TestSession_BadExpiry(t *testing.T) {
1161 +
	if _, ok := verifySession("alice|notanumber|fakesig"); ok {
1162 +
		t.Error("expected invalid with bad expiry")
1163 +
	}
1164 +
}
1165 +
1166 +
func TestGetSessionHandle_NoCookie(t *testing.T) {
1167 +
	req := httptest.NewRequest(http.MethodGet, "/", nil)
1168 +
	if h := getSessionHandle(req); h != "" {
1169 +
		t.Errorf("expected empty, got %q", h)
1170 +
	}
1171 +
}
1172 +
1173 +
func TestGetSessionHandle_ValidCookie(t *testing.T) {
1174 +
	req := httptest.NewRequest(http.MethodGet, "/", nil)
1175 +
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("bob")})
1176 +
	if h := getSessionHandle(req); h != "bob" {
1177 +
		t.Errorf("expected bob, got %q", h)
1178 +
	}
1179 +
}
1180 +
1181 +
func TestGetSessionHandle_InvalidCookie(t *testing.T) {
1182 +
	req := httptest.NewRequest(http.MethodGet, "/", nil)
1183 +
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "bad"})
1184 +
	if h := getSessionHandle(req); h != "" {
1185 +
		t.Errorf("expected empty, got %q", h)
1186 +
	}
1187 +
}
1188 +
1189 +
func TestSetSessionCookie(t *testing.T) {
1190 +
	w := httptest.NewRecorder()
1191 +
	setSessionCookie(w, "carol")
1192 +
	found := false
1193 +
	for _, c := range w.Result().Cookies() {
1194 +
		if c.Name == sessionCookieName {
1195 +
			found = true
1196 +
			if !c.HttpOnly {
1197 +
				t.Error("expected HttpOnly")
1198 +
			}
1199 +
			if c.MaxAge <= 0 {
1200 +
				t.Error("expected positive MaxAge")
1201 +
			}
1202 +
			h, ok := verifySession(c.Value)
1203 +
			if !ok || h != "carol" {
1204 +
				t.Error("cookie value doesn't verify")
1205 +
			}
1206 +
		}
1207 +
	}
1208 +
	if !found {
1209 +
		t.Error("session cookie not set")
1210 +
	}
1211 +
}
1212 +
1213 +
func TestClearSessionCookie(t *testing.T) {
1214 +
	w := httptest.NewRecorder()
1215 +
	clearSessionCookie(w)
1216 +
	found := false
1217 +
	for _, c := range w.Result().Cookies() {
1218 +
		if c.Name == sessionCookieName {
1219 +
			found = true
1220 +
			if c.MaxAge >= 0 {
1221 +
				t.Error("expected negative MaxAge")
1222 +
			}
1223 +
		}
1224 +
	}
1225 +
	if !found {
1226 +
		t.Error("session cookie not set for clearing")
1227 +
	}
1228 +
}
1229 +
1230 +
// ===================================================================
1231 +
// Database CRUD
1232 +
// ===================================================================
1233 +
1234 +
func TestDB_DiscussionCRUD(t *testing.T) {
1235 +
	srv := testServer(t)
1236 +
1237 +
	id, err := createDiscussion(srv.db, "testrepo", "DB Title", "DB Body", "author1")
1238 +
	if err != nil {
1239 +
		t.Fatalf("createDiscussion: %v", err)
1240 +
	}
1241 +
	if id == 0 {
1242 +
		t.Fatal("expected non-zero ID")
1243 +
	}
1244 +
1245 +
	d, err := getDiscussion(srv.db, int(id))
1246 +
	if err != nil {
1247 +
		t.Fatalf("getDiscussion: %v", err)
1248 +
	}
1249 +
	if d.Title != "DB Title" {
1250 +
		t.Errorf("title: %q", d.Title)
1251 +
	}
1252 +
	if d.Body != "DB Body" {
1253 +
		t.Errorf("body: %q", d.Body)
1254 +
	}
1255 +
	if d.Author != "author1" {
1256 +
		t.Errorf("author: %q", d.Author)
1257 +
	}
1258 +
	if d.Repo != "testrepo" {
1259 +
		t.Errorf("repo: %q", d.Repo)
1260 +
	}
1261 +
}
1262 +
1263 +
func TestDB_Replies(t *testing.T) {
1264 +
	srv := testServer(t)
1265 +
	id, _ := createDiscussion(srv.db, "testrepo", "T", "", "a")
1266 +
	createReply(srv.db, int(id), "Reply one", "user1")
1267 +
	createReply(srv.db, int(id), "Reply two", "user2")
1268 +
1269 +
	replies := getReplies(srv.db, int(id))
1270 +
	if len(replies) != 2 {
1271 +
		t.Fatalf("expected 2, got %d", len(replies))
1272 +
	}
1273 +
	if replies[0].Body != "Reply one" || replies[0].Author != "user1" {
1274 +
		t.Errorf("reply 0: %+v", replies[0])
1275 +
	}
1276 +
	if replies[1].Body != "Reply two" || replies[1].Author != "user2" {
1277 +
		t.Errorf("reply 1: %+v", replies[1])
1278 +
	}
1279 +
}
1280 +
1281 +
func TestDB_RepliesEmpty(t *testing.T) {
1282 +
	srv := testServer(t)
1283 +
	if replies := getReplies(srv.db, 99999); len(replies) != 0 {
1284 +
		t.Errorf("expected 0, got %d", len(replies))
1285 +
	}
1286 +
}
1287 +
1288 +
func TestDB_GetDiscussionNotFound(t *testing.T) {
1289 +
	srv := testServer(t)
1290 +
	if _, err := getDiscussion(srv.db, 99999); err == nil {
1291 +
		t.Error("expected error")
1292 +
	}
1293 +
}
1294 +
1295 +
func TestDB_ReplyCount(t *testing.T) {
1296 +
	srv := testServer(t)
1297 +
	id, _ := createDiscussion(srv.db, "testrepo", "Count", "", "a")
1298 +
	createReply(srv.db, int(id), "r1", "b")
1299 +
	createReply(srv.db, int(id), "r2", "c")
1300 +
	createReply(srv.db, int(id), "r3", "d")
1301 +
1302 +
	list := listDiscussions(srv.db, "testrepo")
1303 +
	if len(list) != 1 {
1304 +
		t.Fatalf("expected 1, got %d", len(list))
1305 +
	}
1306 +
	if list[0].ReplyCount != 3 {
1307 +
		t.Errorf("expected 3 replies, got %d", list[0].ReplyCount)
1308 +
	}
1309 +
}
1310 +
1311 +
func TestDB_AvatarUpsert(t *testing.T) {
1312 +
	srv := testServer(t)
1313 +
	upsertAvatar(srv.db, "alice", "https://example.com/v1.png")
1314 +
	if u := getAvatar(srv.db, "alice"); u != "https://example.com/v1.png" {
1315 +
		t.Errorf("got %q", u)
1316 +
	}
1317 +
	upsertAvatar(srv.db, "alice", "https://example.com/v2.png")
1318 +
	if u := getAvatar(srv.db, "alice"); u != "https://example.com/v2.png" {
1319 +
		t.Errorf("got %q", u)
1320 +
	}
1321 +
}
1322 +
1323 +
func TestDB_AvatarNotFound(t *testing.T) {
1324 +
	srv := testServer(t)
1325 +
	if u := getAvatar(srv.db, "nobody"); u != "" {
1326 +
		t.Errorf("expected empty, got %q", u)
1327 +
	}
1328 +
}
1329 +
1330 +
func TestDB_AvatarShownInDiscussion(t *testing.T) {
1331 +
	srv := testServer(t)
1332 +
	upsertAvatar(srv.db, "alice", "https://example.com/alice-av.png")
1333 +
	id, _ := createDiscussion(srv.db, "testrepo", "Avatar Test", "body", "alice")
1334 +
1335 +
	w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
1336 +
	assertCode(t, w, 200)
1337 +
	assertContains(t, w, "https://example.com/alice-av.png")
1338 +
}
1339 +
1340 +
// ===================================================================
1341 +
// scanRepositories
1342 +
// ===================================================================
1343 +
1344 +
func TestScanRepositories(t *testing.T) {
1345 +
	srv := testServer(t)
1346 +
	repos, err := scanRepositories(srv.scanPath, false)
1347 +
	if err != nil {
1348 +
		t.Fatalf("scanRepositories: %v", err)
1349 +
	}
1350 +
	if len(repos) != 1 {
1351 +
		t.Fatalf("expected 1, got %d", len(repos))
1352 +
	}
1353 +
	repo := repos["testrepo"]
1354 +
	if repo == nil {
1355 +
		t.Fatal("expected 'testrepo'")
1356 +
	}
1357 +
	if repo.Description != "A test repository" {
1358 +
		t.Errorf("description: %q", repo.Description)
1359 +
	}
1360 +
	if repo.Owner != "tester" {
1361 +
		t.Errorf("owner: %q", repo.Owner)
1362 +
	}
1363 +
	if repo.LastUpdated.IsZero() {
1364 +
		t.Error("expected non-zero LastUpdated")
1365 +
	}
1366 +
}
1367 +
1368 +
func TestScanRepositories_SkipsPrivate(t *testing.T) {
1369 +
	tmpDir := t.TempDir()
1370 +
	gitRun(t, "", "init", "--bare", filepath.Join(tmpDir, "private.git"))
1371 +
	repos, _ := scanRepositories(tmpDir, false)
1372 +
	if len(repos) != 0 {
1373 +
		t.Errorf("expected 0, got %d", len(repos))
1374 +
	}
1375 +
}
1376 +
1377 +
func TestScanRepositories_SkipsHiddenDirs(t *testing.T) {
1378 +
	tmpDir := t.TempDir()
1379 +
	repoDir := filepath.Join(tmpDir, ".hidden.git")
1380 +
	gitRun(t, "", "init", "--bare", repoDir)
1381 +
	os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
1382 +
	repos, _ := scanRepositories(tmpDir, false)
1383 +
	if len(repos) != 0 {
1384 +
		t.Errorf("expected 0, got %d", len(repos))
1385 +
	}
1386 +
}
1387 +
1388 +
func TestScanRepositories_SkipsFiles(t *testing.T) {
1389 +
	tmpDir := t.TempDir()
1390 +
	os.WriteFile(filepath.Join(tmpDir, "not-a-dir"), []byte("x"), 0644)
1391 +
	repos, _ := scanRepositories(tmpDir, false)
1392 +
	if len(repos) != 0 {
1393 +
		t.Errorf("expected 0, got %d", len(repos))
1394 +
	}
1395 +
}
1396 +
1397 +
func TestScanRepositories_NonBare(t *testing.T) {
1398 +
	tmpDir := t.TempDir()
1399 +
	workDir := filepath.Join(tmpDir, "myrepo")
1400 +
	gitRun(t, "", "init", workDir)
1401 +
	os.WriteFile(filepath.Join(workDir, ".git", "public"), nil, 0644)
1402 +
1403 +
	repos, _ := scanRepositories(tmpDir, false)
1404 +
	if len(repos) != 0 {
1405 +
		t.Errorf("expected 0 without flag, got %d", len(repos))
1406 +
	}
1407 +
	repos, _ = scanRepositories(tmpDir, true)
1408 +
	if len(repos) != 1 {
1409 +
		t.Fatalf("expected 1 with flag, got %d", len(repos))
1410 +
	}
1411 +
}
1412 +
1413 +
func TestScanRepositories_StripsGitSuffix(t *testing.T) {
1414 +
	srv := testServer(t)
1415 +
	repos, _ := scanRepositories(srv.scanPath, false)
1416 +
	if _, ok := repos["testrepo"]; !ok {
1417 +
		t.Error("expected .git suffix stripped")
1418 +
	}
1419 +
}
1420 +
1421 +
func TestScanRepositories_DefaultDescription(t *testing.T) {
1422 +
	tmpDir := t.TempDir()
1423 +
	repoDir := filepath.Join(tmpDir, "repo.git")
1424 +
	gitRun(t, "", "init", "--bare", repoDir)
1425 +
	os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
1426 +
	repos, _ := scanRepositories(tmpDir, false)
1427 +
	if r, ok := repos["repo"]; ok && r.Description != "" {
1428 +
		t.Errorf("default description should be empty, got %q", r.Description)
1429 +
	}
1430 +
}
1431 +
1432 +
// ===================================================================
1433 +
// isBareRepo / isWorkTreeRepo
1434 +
// ===================================================================
1435 +
1436 +
func TestIsBareRepo(t *testing.T) {
1437 +
	tmpDir := t.TempDir()
1438 +
	repoDir := filepath.Join(tmpDir, "bare.git")
1439 +
	gitRun(t, "", "init", "--bare", repoDir)
1440 +
	if !isBareRepo(repoDir) {
1441 +
		t.Error("expected bare repo")
1442 +
	}
1443 +
	if isBareRepo(tmpDir) {
1444 +
		t.Error("tmpDir is not bare")
1445 +
	}
1446 +
}
1447 +
1448 +
func TestIsWorkTreeRepo(t *testing.T) {
1449 +
	tmpDir := t.TempDir()
1450 +
	workDir := filepath.Join(tmpDir, "work")
1451 +
	gitRun(t, "", "init", workDir)
1452 +
	if !isWorkTreeRepo(workDir) {
1453 +
		t.Error("expected work tree repo")
1454 +
	}
1455 +
	if isWorkTreeRepo(tmpDir) {
1456 +
		t.Error("tmpDir is not work tree")
1457 +
	}
1458 +
}
1459 +
1460 +
// ===================================================================
1461 +
// Template FuncMap
1462 +
// ===================================================================
1463 +
1464 +
func TestFuncMap_ShortHash(t *testing.T) {
1465 +
	fn := funcMap["shortHash"].(func(string) string)
1466 +
	if got := fn("abcdef1234567890"); got != "abcdef12" {
1467 +
		t.Errorf("got %q", got)
1468 +
	}
1469 +
	if got := fn("abc"); got != "abc" {
1470 +
		t.Errorf("got %q", got)
1471 +
	}
1472 +
	if got := fn(""); got != "" {
1473 +
		t.Errorf("got %q", got)
1474 +
	}
1475 +
}
1476 +
1477 +
func TestFuncMap_StatusLabel(t *testing.T) {
1478 +
	fn := funcMap["statusLabel"].(func(string) string)
1479 +
	cases := map[string]string{"A": "added", "D": "deleted", "M": "modified", "R": "renamed", "X": "X"}
1480 +
	for in, want := range cases {
1481 +
		if got := fn(in); got != want {
1482 +
			t.Errorf("statusLabel(%q) = %q, want %q", in, got, want)
1483 +
		}
1484 +
	}
1485 +
}
1486 +
1487 +
func TestFuncMap_FormatSize(t *testing.T) {
1488 +
	fn := funcMap["formatSize"].(func(int64) string)
1489 +
	if got := fn(500); got != "500 B" {
1490 +
		t.Errorf("got %q", got)
1491 +
	}
1492 +
	if got := fn(2048); !strings.Contains(got, "KiB") {
1493 +
		t.Errorf("got %q", got)
1494 +
	}
1495 +
	if got := fn(2 * 1024 * 1024); !strings.Contains(got, "MiB") {
1496 +
		t.Errorf("got %q", got)
1497 +
	}
1498 +
}
1499 +
1500 +
func TestFuncMap_DiffFileName(t *testing.T) {
1501 +
	fn := funcMap["diffFileName"].(func(DiffFile) string)
1502 +
	if got := fn(DiffFile{OldName: "old.go", NewName: "new.go"}); got != "new.go" {
1503 +
		t.Errorf("got %q", got)
1504 +
	}
1505 +
	if got := fn(DiffFile{OldName: "old.go", NewName: ""}); got != "old.go" {
1506 +
		t.Errorf("got %q", got)
1507 +
	}
1508 +
}
1509 +
1510 +
func TestFuncMap_ParentPath(t *testing.T) {
1511 +
	fn := funcMap["parentPath"].(func(string) string)
1512 +
	if got := fn("a/b/c"); got != "a/b" {
1513 +
		t.Errorf("got %q", got)
1514 +
	}
1515 +
	if got := fn("file.go"); got != "" {
1516 +
		t.Errorf("got %q", got)
1517 +
	}
1518 +
}
1519 +
1520 +
func TestFuncMap_Indent(t *testing.T) {
1521 +
	fn := funcMap["indent"].(func(int) string)
1522 +
	if got := fn(0); got != "" {
1523 +
		t.Errorf("got %q", got)
1524 +
	}
1525 +
	if got := fn(2); !strings.Contains(got, "padding-left") {
1526 +
		t.Errorf("got %q", got)
1527 +
	}
1528 +
}
1529 +
1530 +
func TestFuncMap_LangClass(t *testing.T) {
1531 +
	fn := funcMap["langClass"].(func(string) string)
1532 +
	if got := fn("file.rad"); got != "language-radiance" {
1533 +
		t.Errorf("got %q", got)
1534 +
	}
1535 +
	if got := fn("file.ril"); got != "language-ril" {
1536 +
		t.Errorf("got %q", got)
1537 +
	}
1538 +
	if got := fn("file.go"); got != "" {
1539 +
		t.Errorf("got %q", got)
1540 +
	}
1541 +
}
1542 +
1543 +
func TestFuncMap_Add(t *testing.T) {
1544 +
	fn := funcMap["add"].(func(int, int) int)
1545 +
	if got := fn(3, 4); got != 7 {
1546 +
		t.Errorf("got %d", got)
1547 +
	}
1548 +
}
1549 +
1550 +
func TestFuncMap_Autolink(t *testing.T) {
1551 +
	fn := funcMap["autolink"].(func(string) template.HTML)
1552 +
	tests := []struct {
1553 +
		input    string
1554 +
		contains string
1555 +
	}{
1556 +
		{"hello https://example.com world", `<a href="https://example.com">https://example.com</a>`},
1557 +
		{"no links here", "no links here"},
1558 +
		{"http://foo.com.", `<a href="http://foo.com">http://foo.com</a>`},
1559 +
		{"<script>", "&lt;script&gt;"},
1560 +
	}
1561 +
	for _, tt := range tests {
1562 +
		got := string(fn(tt.input))
1563 +
		if !strings.Contains(got, tt.contains) {
1564 +
			t.Errorf("autolink(%q):\n  got:  %s\n  want substring: %s", tt.input, got, tt.contains)
1565 +
		}
1566 +
	}
1567 +
}
1568 +
1569 +
func TestFormatInline(t *testing.T) {
1570 +
	tests := []struct {
1571 +
		input string
1572 +
		want  string
1573 +
	}{
1574 +
		// Backtick code
1575 +
		{"use `fmt.Println` here", "use <code>fmt.Println</code> here"},
1576 +
		// Code with special chars
1577 +
		{"run `<script>`", "run <code>&lt;script&gt;</code>"},
1578 +
		// Italic
1579 +
		{"this is *important* stuff", "this is <em>important</em> stuff"},
1580 +
		// Single-char italic
1581 +
		{"*a* test", "<em>a</em> test"},
1582 +
		// Angle-bracket link
1583 +
		{"see <https://example.com> for info", `see <a href="https://example.com">https://example.com</a> for info`},
1584 +
		// Bare URL (existing behavior)
1585 +
		{"visit https://example.com today", `visit <a href="https://example.com">https://example.com</a> today`},
1586 +
		// Bare URL with trailing punctuation
1587 +
		{"see https://example.com.", `see <a href="https://example.com">https://example.com</a>.`},
1588 +
		// Mixed formatting
1589 +
		{"use `code` and *italic* and <https://x.com>",
1590 +
			`use <code>code</code> and <em>italic</em> and <a href="https://x.com">https://x.com</a>`},
1591 +
		// No formatting
1592 +
		{"plain text", "plain text"},
1593 +
		// HTML escaping in plain text
1594 +
		{"<b>bold</b>", "&lt;b&gt;bold&lt;/b&gt;"},
1595 +
		// Asterisk without match (not italic)
1596 +
		{"a * b * c", "a * b * c"},
1597 +
		// Italic must not have leading/trailing spaces
1598 +
		{"not * italic *", "not * italic *"},
1599 +
	}
1600 +
	for _, tt := range tests {
1601 +
		got := formatInline(tt.input)
1602 +
		if got != tt.want {
1603 +
			t.Errorf("formatInline(%q):\n  got:  %s\n  want: %s", tt.input, got, tt.want)
1604 +
		}
1605 +
	}
1606 +
}
1607 +
1608 +
func TestFuncMap_FormatBody(t *testing.T) {
1609 +
	fn := funcMap["formatBody"].(func(string) template.HTML)
1610 +
	tests := []struct {
1611 +
		input string
1612 +
		want  string
1613 +
	}{
1614 +
		// Single paragraph with code
1615 +
		{"hello `world`", "<p>hello <code>world</code></p>"},
1616 +
		// Two paragraphs
1617 +
		{"first\n\nsecond", "<p>first</p><p>second</p>"},
1618 +
		// Paragraph with italic and link
1619 +
		{"*wow* see <https://x.com>", `<p><em>wow</em> see <a href="https://x.com">https://x.com</a></p>`},
1620 +
		// Empty input
1621 +
		{"", ""},
1622 +
		{"  \n\n  ", ""},
1623 +
		// Fenced code block
1624 +
		{"before\n\n```\nfmt.Println(\"hi\")\n```\n\nafter",
1625 +
			"<p>before</p><pre><code>fmt.Println(&#34;hi&#34;)</code></pre><p>after</p>"},
1626 +
		// Code block with language tag
1627 +
		{"```go\npackage main\n```",
1628 +
			"<pre><code>package main</code></pre>"},
1629 +
		// Code block only
1630 +
		{"```\nline1\nline2\n```",
1631 +
			"<pre><code>line1\nline2</code></pre>"},
1632 +
		// No inline formatting inside code blocks
1633 +
		{"```\n*not italic* `not code`\n```",
1634 +
			"<pre><code>*not italic* `not code`</code></pre>"},
1635 +
		// Unclosed code block
1636 +
		{"```\nhello",
1637 +
			"<pre><code>hello</code></pre>"},
1638 +
		// HTML escaped inside code block
1639 +
		{"```\n<div>test</div>\n```",
1640 +
			"<pre><code>&lt;div&gt;test&lt;/div&gt;</code></pre>"},
1641 +
	}
1642 +
	for _, tt := range tests {
1643 +
		got := string(fn(tt.input))
1644 +
		if got != tt.want {
1645 +
			t.Errorf("formatBody(%q):\n  got:  %s\n  want: %s", tt.input, got, tt.want)
1646 +
		}
1647 +
	}
1648 +
}
1649 +
1650 +
func TestFuncMap_DiffBar_Zero(t *testing.T) {
1651 +
	fn := funcMap["diffBar"].(func(int, int) template.HTML)
1652 +
	got := fn(0, 0)
1653 +
	if string(got) != "" {
1654 +
		t.Errorf("expected empty, got %q", got)
1655 +
	}
1656 +
}
1657 +
1658 +
func TestFuncMap_DiffBar_AddOnly(t *testing.T) {
1659 +
	fn := funcMap["diffBar"].(func(int, int) template.HTML)
1660 +
	got := string(fn(5, 0))
1661 +
	if !strings.Contains(got, "bar-add") {
1662 +
		t.Errorf("expected bar-add, got %q", got)
1663 +
	}
1664 +
}
1665 +
1666 +
func TestFuncMap_DiffBar_Mixed(t *testing.T) {
1667 +
	fn := funcMap["diffBar"].(func(int, int) template.HTML)
1668 +
	got := string(fn(3, 2))
1669 +
	if !strings.Contains(got, "bar-add") || !strings.Contains(got, "bar-del") {
1670 +
		t.Errorf("expected both bar-add and bar-del, got %q", got)
1671 +
	}
1672 +
}
1673 +
1674 +
// ===================================================================
1675 +
// Template rendering
1676 +
// ===================================================================
1677 +
1678 +
func TestTemplateRender_UnknownTemplate(t *testing.T) {
1679 +
	srv := testServer(t)
1680 +
	w := httptest.NewRecorder()
1681 +
	srv.tmpl.render(w, "nonexistent", nil)
1682 +
	if !strings.Contains(w.Body.String(), "not found") {
1683 +
		t.Error("expected 'not found' for unknown template")
1684 +
	}
1685 +
}
1686 +
1687 +
func TestLoadTemplates(t *testing.T) {
1688 +
	ts, err := loadTemplates()
1689 +
	if err != nil {
1690 +
		t.Fatalf("loadTemplates: %v", err)
1691 +
	}
1692 +
	for _, name := range []string{"index", "home", "log", "commit", "refs", "error", "discussions", "discussion", "discussion_new", "login"} {
1693 +
		if _, ok := ts.templates[name]; !ok {
1694 +
			t.Errorf("missing template %q", name)
1695 +
		}
1696 +
	}
1697 +
}
1698 +
1699 +
// ===================================================================
1700 +
// Git logic: sortTreeEntries, collapseContext, mimeFromPath,
1701 +
// parseDiffOutput, parseHunkHeader
1702 +
// ===================================================================
1703 +
1704 +
func TestSortTreeEntries(t *testing.T) {
1705 +
	entries := []TreeEntryInfo{
1706 +
		{Name: "b.go", IsDir: false},
1707 +
		{Name: "a.go", IsDir: false},
1708 +
		{Name: "z-dir", IsDir: true},
1709 +
		{Name: "a-dir", IsDir: true},
1710 +
	}
1711 +
	sortTreeEntries(entries)
1712 +
	if entries[0].Name != "a-dir" || entries[1].Name != "z-dir" {
1713 +
		t.Errorf("dirs not first: %v", entries)
1714 +
	}
1715 +
	if entries[2].Name != "a.go" || entries[3].Name != "b.go" {
1716 +
		t.Errorf("files not sorted: %v", entries)
1717 +
	}
1718 +
}
1719 +
1720 +
func TestCollapseContext_Empty(t *testing.T) {
1721 +
	if hunks := collapseContext(nil); hunks != nil {
1722 +
		t.Errorf("expected nil, got %v", hunks)
1723 +
	}
1724 +
}
1725 +
1726 +
func TestCollapseContext_AllChanges(t *testing.T) {
1727 +
	lines := []DiffLine{
1728 +
		{Type: "add", Content: "a", NewNum: 1},
1729 +
		{Type: "del", Content: "b", OldNum: 1},
1730 +
	}
1731 +
	hunks := collapseContext(lines)
1732 +
	if len(hunks) != 1 {
1733 +
		t.Fatalf("expected 1, got %d", len(hunks))
1734 +
	}
1735 +
	if len(hunks[0].Lines) != 2 {
1736 +
		t.Errorf("expected 2 lines, got %d", len(hunks[0].Lines))
1737 +
	}
1738 +
}
1739 +
1740 +
func TestCollapseContext_SplitsDistantChanges(t *testing.T) {
1741 +
	var lines []DiffLine
1742 +
	lines = append(lines, DiffLine{Type: "add", Content: "first", NewNum: 1})
1743 +
	for i := 0; i < 20; i++ {
1744 +
		lines = append(lines, DiffLine{Type: "context", Content: "ctx", OldNum: i + 1, NewNum: i + 2})
1745 +
	}
1746 +
	lines = append(lines, DiffLine{Type: "add", Content: "second", NewNum: 22})
1747 +
1748 +
	hunks := collapseContext(lines)
1749 +
	if len(hunks) != 2 {
1750 +
		t.Errorf("expected 2 hunks, got %d", len(hunks))
1751 +
	}
1752 +
}
1753 +
1754 +
func TestCollapseContext_KeepsNearbyContext(t *testing.T) {
1755 +
	var lines []DiffLine
1756 +
	lines = append(lines, DiffLine{Type: "context", Content: "before", OldNum: 1, NewNum: 1})
1757 +
	lines = append(lines, DiffLine{Type: "add", Content: "new", NewNum: 2})
1758 +
	lines = append(lines, DiffLine{Type: "context", Content: "after", OldNum: 2, NewNum: 3})
1759 +
1760 +
	hunks := collapseContext(lines)
1761 +
	if len(hunks) != 1 {
1762 +
		t.Fatalf("expected 1 hunk, got %d", len(hunks))
1763 +
	}
1764 +
	if len(hunks[0].Lines) != 3 {
1765 +
		t.Errorf("expected 3 lines (context around change), got %d", len(hunks[0].Lines))
1766 +
	}
1767 +
}
1768 +
1769 +
func TestMimeFromPath(t *testing.T) {
1770 +
	tests := map[string]string{
1771 +
		"file.go":   "text/plain; charset=utf-8",
1772 +
		"file.png":  "image/png",
1773 +
		"file.pdf":  "application/pdf",
1774 +
		"file.css":  "text/css; charset=utf-8",
1775 +
		"file.json": "application/json",
1776 +
		"file.svg":  "image/svg+xml",
1777 +
		"file.RS":   "text/plain; charset=utf-8",
1778 +
		"noext":     "",
1779 +
		"file.xyz":  "",
1780 +
	}
1781 +
	for path, want := range tests {
1782 +
		if got := mimeFromPath(path); got != want {
1783 +
			t.Errorf("mimeFromPath(%q) = %q, want %q", path, got, want)
1784 +
		}
1785 +
	}
1786 +
}
1787 +
1788 +
func TestParseDiffOutput_Added(t *testing.T) {
1789 +
	output := `
file.go +0 -0
old.go +0 -0
new.go +0 -0
image.png +0 -0
file.go +0 -0
a.go +0 -0
b.go +0 -0