handler_test.go 69.0 KiB raw
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 := `diff --git a/file.go b/file.go
1790
new file mode 100644
1791
index 0000000..abc1234
1792
--- /dev/null
1793
+++ b/file.go
1794
@@ -0,0 +1,3 @@
1795
+package main
1796
+
1797
+func main() {}
1798
`
1799
	files := parseDiffOutput(output)
1800
	if len(files) != 1 {
1801
		t.Fatalf("expected 1, got %d", len(files))
1802
	}
1803
	if files[0].Status != "A" {
1804
		t.Errorf("status: %q", files[0].Status)
1805
	}
1806
	if files[0].NewName != "file.go" {
1807
		t.Errorf("name: %q", files[0].NewName)
1808
	}
1809
	if files[0].Stats.Added != 3 {
1810
		t.Errorf("added: %d", files[0].Stats.Added)
1811
	}
1812
}
1813
1814
func TestParseDiffOutput_Deleted(t *testing.T) {
1815
	output := `diff --git a/old.go b/old.go
1816
deleted file mode 100644
1817
index abc1234..0000000
1818
--- a/old.go
1819
+++ /dev/null
1820
@@ -1,2 +0,0 @@
1821
-package old
1822
-
1823
`
1824
	files := parseDiffOutput(output)
1825
	if len(files) != 1 {
1826
		t.Fatalf("expected 1, got %d", len(files))
1827
	}
1828
	if files[0].Status != "D" {
1829
		t.Errorf("status: %q", files[0].Status)
1830
	}
1831
	if files[0].Stats.Deleted != 2 {
1832
		t.Errorf("deleted: %d", files[0].Stats.Deleted)
1833
	}
1834
}
1835
1836
func TestParseDiffOutput_Rename(t *testing.T) {
1837
	output := `diff --git a/old.go b/new.go
1838
rename from old.go
1839
rename to new.go
1840
`
1841
	files := parseDiffOutput(output)
1842
	if len(files) != 1 {
1843
		t.Fatalf("expected 1, got %d", len(files))
1844
	}
1845
	if files[0].Status != "R" {
1846
		t.Errorf("status: %q", files[0].Status)
1847
	}
1848
}
1849
1850
func TestParseDiffOutput_Binary(t *testing.T) {
1851
	output := `diff --git a/image.png b/image.png
1852
new file mode 100644
1853
Binary files /dev/null and b/image.png differ
1854
`
1855
	files := parseDiffOutput(output)
1856
	if len(files) != 1 {
1857
		t.Fatalf("expected 1, got %d", len(files))
1858
	}
1859
	if !files[0].IsBinary {
1860
		t.Error("expected binary")
1861
	}
1862
}
1863
1864
func TestParseDiffOutput_Modified(t *testing.T) {
1865
	output := `diff --git a/file.go b/file.go
1866
index abc..def 100644
1867
--- a/file.go
1868
+++ b/file.go
1869
@@ -1,3 +1,4 @@
1870
 package main
1871
 
1872
 func main() {}
1873
+func extra() {}
1874
`
1875
	files := parseDiffOutput(output)
1876
	if len(files) != 1 {
1877
		t.Fatalf("expected 1, got %d", len(files))
1878
	}
1879
	if files[0].Status != "M" {
1880
		t.Errorf("status: %q", files[0].Status)
1881
	}
1882
	if files[0].Stats.Added != 1 {
1883
		t.Errorf("added: %d", files[0].Stats.Added)
1884
	}
1885
}
1886
1887
func TestParseDiffOutput_MultipleFiles(t *testing.T) {
1888
	output := `diff --git a/a.go b/a.go
1889
new file mode 100644
1890
--- /dev/null
1891
+++ b/a.go
1892
@@ -0,0 +1 @@
1893
+package a
1894
diff --git a/b.go b/b.go
1895
new file mode 100644
1896
--- /dev/null
1897
+++ b/b.go
1898
@@ -0,0 +1 @@
1899
+package b
1900
`
1901
	files := parseDiffOutput(output)
1902
	if len(files) != 2 {
1903
		t.Fatalf("expected 2, got %d", len(files))
1904
	}
1905
}
1906
1907
func TestParseDiffOutput_Empty(t *testing.T) {
1908
	files := parseDiffOutput("")
1909
	if len(files) != 0 {
1910
		t.Errorf("expected 0, got %d", len(files))
1911
	}
1912
}
1913
1914
func TestParseHunkHeader(t *testing.T) {
1915
	tests := []struct {
1916
		input string
1917
		want  []int
1918
	}{
1919
		{"@@ -1,5 +1,7 @@", []int{1, 1}},
1920
		{"@@ -10,3 +20,5 @@ func foo()", []int{10, 20}},
1921
		{"@@ -1 +1 @@", []int{1, 1}},
1922
		{"no hunk", nil},
1923
		{"@@  @@", nil},
1924
	}
1925
	for _, tt := range tests {
1926
		got := parseHunkHeader(tt.input)
1927
		if tt.want == nil {
1928
			if got != nil {
1929
				t.Errorf("parseHunkHeader(%q) = %v, want nil", tt.input, got)
1930
			}
1931
		} else {
1932
			if got == nil || got[0] != tt.want[0] || got[1] != tt.want[1] {
1933
				t.Errorf("parseHunkHeader(%q) = %v, want %v", tt.input, got, tt.want)
1934
			}
1935
		}
1936
	}
1937
}
1938
1939
// ===================================================================
1940
// Git CLI backend (integration with real repo)
1941
// ===================================================================
1942
1943
func TestGit_ResolveRef(t *testing.T) {
1944
	srv := testServer(t)
1945
	git := srv.repos["testrepo"].Git
1946
	hash, err := git.resolveRef(git.getDefaultBranch())
1947
	if err != nil {
1948
		t.Fatalf("resolveRef: %v", err)
1949
	}
1950
	if len(hash) < 40 {
1951
		t.Errorf("expected full hash, got %q", hash)
1952
	}
1953
}
1954
1955
func TestGit_ResolveRefEmpty(t *testing.T) {
1956
	srv := testServer(t)
1957
	git := srv.repos["testrepo"].Git
1958
	hash, err := git.resolveRef("")
1959
	if err != nil {
1960
		t.Fatalf("resolveRef empty: %v", err)
1961
	}
1962
	if len(hash) < 40 {
1963
		t.Errorf("expected full hash, got %q", hash)
1964
	}
1965
}
1966
1967
func TestGit_ResolveRefInvalid(t *testing.T) {
1968
	srv := testServer(t)
1969
	git := srv.repos["testrepo"].Git
1970
	if _, err := git.resolveRef("nonexistent"); err == nil {
1971
		t.Error("expected error")
1972
	}
1973
}
1974
1975
func TestGit_ResolveRefAndPath(t *testing.T) {
1976
	srv := testServer(t)
1977
	git := srv.repos["testrepo"].Git
1978
	ref := git.getDefaultBranch()
1979
1980
	hash, gotRef, path, err := git.resolveRefAndPath(strings.Split(ref+"/main.go", "/"))
1981
	if err != nil {
1982
		t.Fatalf("resolveRefAndPath: %v", err)
1983
	}
1984
	if gotRef != ref {
1985
		t.Errorf("ref: %q, want %q", gotRef, ref)
1986
	}
1987
	if path != "main.go" {
1988
		t.Errorf("path: %q", path)
1989
	}
1990
	if len(hash) < 40 {
1991
		t.Errorf("hash: %q", hash)
1992
	}
1993
}
1994
1995
func TestGit_ResolveRefAndPath_SlashBranch(t *testing.T) {
1996
	srv := testServer(t)
1997
	git := srv.repos["testrepo"].Git
1998
1999
	_, ref, path, err := git.resolveRefAndPath([]string{"feature", "test", "feature.txt"})
2000
	if err != nil {
2001
		t.Fatalf("resolveRefAndPath: %v", err)
2002
	}
2003
	if ref != "feature/test" {
2004
		t.Errorf("ref: %q", ref)
2005
	}
2006
	if path != "feature.txt" {
2007
		t.Errorf("path: %q", path)
2008
	}
2009
}
2010
2011
func TestGit_ResolveRefAndPath_RefOnly(t *testing.T) {
2012
	srv := testServer(t)
2013
	git := srv.repos["testrepo"].Git
2014
	ref := git.getDefaultBranch()
2015
2016
	_, gotRef, path, err := git.resolveRefAndPath([]string{ref})
2017
	if err != nil {
2018
		t.Fatalf("resolveRefAndPath ref only: %v", err)
2019
	}
2020
	if gotRef != ref {
2021
		t.Errorf("ref: %q", gotRef)
2022
	}
2023
	if path != "" {
2024
		t.Errorf("path should be empty: %q", path)
2025
	}
2026
}
2027
2028
func TestGit_ResolveRefAndPath_Invalid(t *testing.T) {
2029
	srv := testServer(t)
2030
	git := srv.repos["testrepo"].Git
2031
	if _, _, _, err := git.resolveRefAndPath([]string{"no", "such", "ref"}); err == nil {
2032
		t.Error("expected error")
2033
	}
2034
}
2035
2036
func TestGit_GetDefaultBranch(t *testing.T) {
2037
	srv := testServer(t)
2038
	git := srv.repos["testrepo"].Git
2039
	branch := git.getDefaultBranch()
2040
	if branch == "" {
2041
		t.Error("expected non-empty")
2042
	}
2043
	if _, err := git.resolveRef(branch); err != nil {
2044
		t.Errorf("branch %q not resolvable: %v", branch, err)
2045
	}
2046
}
2047
2048
func TestGit_GetBranches(t *testing.T) {
2049
	srv := testServer(t)
2050
	git := srv.repos["testrepo"].Git
2051
	branches, err := git.getBranches()
2052
	if err != nil {
2053
		t.Fatalf("getBranches: %v", err)
2054
	}
2055
	if len(branches) < 2 {
2056
		t.Fatalf("expected >= 2, got %d", len(branches))
2057
	}
2058
	names := make(map[string]bool)
2059
	for _, b := range branches {
2060
		names[b.Name] = true
2061
		if b.Hash == "" || b.ShortHash == "" {
2062
			t.Errorf("branch %q has empty hash", b.Name)
2063
		}
2064
		if b.IsTag {
2065
			t.Errorf("branch %q should not be tag", b.Name)
2066
		}
2067
	}
2068
	if !names["feature/test"] {
2069
		t.Error("expected feature/test")
2070
	}
2071
}
2072
2073
func TestGit_GetTags(t *testing.T) {
2074
	srv := testServer(t)
2075
	git := srv.repos["testrepo"].Git
2076
	tags, err := git.getTags()
2077
	if err != nil {
2078
		t.Fatalf("getTags: %v", err)
2079
	}
2080
	if len(tags) != 1 {
2081
		t.Fatalf("expected 1, got %d", len(tags))
2082
	}
2083
	if tags[0].Name != "v1.0" {
2084
		t.Errorf("name: %q", tags[0].Name)
2085
	}
2086
	if !tags[0].IsTag {
2087
		t.Error("expected IsTag")
2088
	}
2089
	if tags[0].Subject != "Release v1.0" {
2090
		t.Errorf("subject: %q", tags[0].Subject)
2091
	}
2092
}
2093
2094
func TestGit_GetTree(t *testing.T) {
2095
	srv := testServer(t)
2096
	git := srv.repos["testrepo"].Git
2097
	hash, _ := git.resolveRef(git.getDefaultBranch())
2098
2099
	entries, err := git.getTree(hash, "")
2100
	if err != nil {
2101
		t.Fatalf("getTree: %v", err)
2102
	}
2103
	if len(entries) == 0 {
2104
		t.Fatal("expected entries")
2105
	}
2106
	// Dirs first.
2107
	seenFile := false
2108
	for _, e := range entries {
2109
		if !e.IsDir {
2110
			seenFile = true
2111
		} else if seenFile {
2112
			t.Error("dir appeared after file โ€” not sorted")
2113
		}
2114
	}
2115
}
2116
2117
func TestGit_GetTree_Subdir(t *testing.T) {
2118
	srv := testServer(t)
2119
	git := srv.repos["testrepo"].Git
2120
	hash, _ := git.resolveRef(git.getDefaultBranch())
2121
2122
	entries, err := git.getTree(hash, "pkg")
2123
	if err != nil {
2124
		t.Fatalf("getTree pkg: %v", err)
2125
	}
2126
	if len(entries) != 1 || entries[0].Name != "lib.go" {
2127
		t.Errorf("expected lib.go, got %v", entries)
2128
	}
2129
}
2130
2131
func TestGit_GetBlob(t *testing.T) {
2132
	srv := testServer(t)
2133
	git := srv.repos["testrepo"].Git
2134
	hash, _ := git.resolveRef(git.getDefaultBranch())
2135
2136
	blob, err := git.getBlob(hash, "main.go")
2137
	if err != nil {
2138
		t.Fatalf("getBlob: %v", err)
2139
	}
2140
	if blob.Name != "main.go" {
2141
		t.Errorf("name: %q", blob.Name)
2142
	}
2143
	if blob.IsBinary {
2144
		t.Error("expected non-binary")
2145
	}
2146
	if !strings.Contains(blob.Content, "package main") {
2147
		t.Error("expected content")
2148
	}
2149
	if blob.Size == 0 {
2150
		t.Error("expected non-zero size")
2151
	}
2152
	if len(blob.Lines) == 0 {
2153
		t.Error("expected lines")
2154
	}
2155
}
2156
2157
func TestGit_GetBlob_Nested(t *testing.T) {
2158
	srv := testServer(t)
2159
	git := srv.repos["testrepo"].Git
2160
	hash, _ := git.resolveRef(git.getDefaultBranch())
2161
2162
	blob, err := git.getBlob(hash, "pkg/lib.go")
2163
	if err != nil {
2164
		t.Fatalf("getBlob: %v", err)
2165
	}
2166
	// Name should be the basename.
2167
	if blob.Name != "lib.go" {
2168
		t.Errorf("name: %q", blob.Name)
2169
	}
2170
}
2171
2172
func TestGit_GetBlob_NotFound(t *testing.T) {
2173
	srv := testServer(t)
2174
	git := srv.repos["testrepo"].Git
2175
	hash, _ := git.resolveRef(git.getDefaultBranch())
2176
	if _, err := git.getBlob(hash, "nonexistent.txt"); err == nil {
2177
		t.Error("expected error")
2178
	}
2179
}
2180
2181
func TestGit_GetRawBlob(t *testing.T) {
2182
	srv := testServer(t)
2183
	git := srv.repos["testrepo"].Git
2184
	hash, _ := git.resolveRef(git.getDefaultBranch())
2185
2186
	reader, ct, size, err := git.getRawBlob(hash, "main.go")
2187
	if err != nil {
2188
		t.Fatalf("getRawBlob: %v", err)
2189
	}
2190
	defer reader.Close()
2191
	if ct != "text/plain; charset=utf-8" {
2192
		t.Errorf("content-type: %q", ct)
2193
	}
2194
	if size == 0 {
2195
		t.Error("expected non-zero size")
2196
	}
2197
	buf := make([]byte, 1024)
2198
	n, _ := reader.Read(buf)
2199
	if !strings.Contains(string(buf[:n]), "package main") {
2200
		t.Error("expected content from reader")
2201
	}
2202
}
2203
2204
func TestGit_IsTreePath(t *testing.T) {
2205
	srv := testServer(t)
2206
	git := srv.repos["testrepo"].Git
2207
	hash, _ := git.resolveRef(git.getDefaultBranch())
2208
2209
	if !git.isTreePath(hash, "") {
2210
		t.Error("empty path should be tree")
2211
	}
2212
	if !git.isTreePath(hash, "pkg") {
2213
		t.Error("pkg should be tree")
2214
	}
2215
	if git.isTreePath(hash, "main.go") {
2216
		t.Error("main.go should not be tree")
2217
	}
2218
	if git.isTreePath(hash, "nonexistent") {
2219
		t.Error("nonexistent should not be tree")
2220
	}
2221
}
2222
2223
func TestGit_BuildTreeNodes(t *testing.T) {
2224
	srv := testServer(t)
2225
	git := srv.repos["testrepo"].Git
2226
	hash, _ := git.resolveRef(git.getDefaultBranch())
2227
2228
	nodes := git.buildTreeNodes(hash, "")
2229
	if len(nodes) == 0 {
2230
		t.Fatal("expected nodes")
2231
	}
2232
	for _, n := range nodes {
2233
		if n.Depth != 0 {
2234
			t.Errorf("expected depth 0 for %q, got %d", n.Name, n.Depth)
2235
		}
2236
		if n.IsOpen {
2237
			t.Errorf("expected not open: %q", n.Name)
2238
		}
2239
	}
2240
}
2241
2242
func TestGit_BuildTreeNodes_ActiveFile(t *testing.T) {
2243
	srv := testServer(t)
2244
	git := srv.repos["testrepo"].Git
2245
	hash, _ := git.resolveRef(git.getDefaultBranch())
2246
2247
	nodes := git.buildTreeNodes(hash, "pkg/lib.go")
2248
	var pkgOpen, libActive bool
2249
	for _, n := range nodes {
2250
		if n.Name == "pkg" && n.IsOpen {
2251
			pkgOpen = true
2252
		}
2253
		if n.Name == "lib.go" && n.IsActive && n.Depth == 1 {
2254
			libActive = true
2255
		}
2256
	}
2257
	if !pkgOpen {
2258
		t.Error("pkg should be open")
2259
	}
2260
	if !libActive {
2261
		t.Error("lib.go should be active at depth 1")
2262
	}
2263
}
2264
2265
func TestGit_BuildTreeNodes_ActiveDir(t *testing.T) {
2266
	srv := testServer(t)
2267
	git := srv.repos["testrepo"].Git
2268
	hash, _ := git.resolveRef(git.getDefaultBranch())
2269
2270
	nodes := git.buildTreeNodes(hash, "pkg")
2271
	var found bool
2272
	for _, n := range nodes {
2273
		if n.Name == "pkg" && n.IsActive && n.IsOpen {
2274
			found = true
2275
		}
2276
	}
2277
	if !found {
2278
		t.Error("pkg should be active and open")
2279
	}
2280
}
2281
2282
func TestGit_GetReadme(t *testing.T) {
2283
	srv := testServer(t)
2284
	git := srv.repos["testrepo"].Git
2285
	hash, _ := git.resolveRef(git.getDefaultBranch())
2286
2287
	readme := git.getReadme(hash, "")
2288
	if readme == nil {
2289
		t.Fatal("expected README")
2290
	}
2291
	if readme.Name != "README.md" {
2292
		t.Errorf("name: %q", readme.Name)
2293
	}
2294
	if !strings.Contains(readme.Content, "Hello world") {
2295
		t.Error("expected content")
2296
	}
2297
}
2298
2299
func TestGit_GetReadme_NotFound(t *testing.T) {
2300
	srv := testServer(t)
2301
	git := srv.repos["testrepo"].Git
2302
	hash, _ := git.resolveRef(git.getDefaultBranch())
2303
	if readme := git.getReadme(hash, "pkg"); readme != nil {
2304
		t.Errorf("expected nil for dir without README, got %+v", readme)
2305
	}
2306
}
2307
2308
func TestGit_GetLog(t *testing.T) {
2309
	srv := testServer(t)
2310
	git := srv.repos["testrepo"].Git
2311
	hash, _ := git.resolveRef(git.getDefaultBranch())
2312
2313
	commits, hasMore, err := git.getLog(hash, 0, 50)
2314
	if err != nil {
2315
		t.Fatalf("getLog: %v", err)
2316
	}
2317
	if len(commits) < 2 {
2318
		t.Fatalf("expected >= 2, got %d", len(commits))
2319
	}
2320
	if hasMore {
2321
		t.Error("should not have more")
2322
	}
2323
	if commits[0].Subject != "Add extra file" {
2324
		t.Errorf("first: %q", commits[0].Subject)
2325
	}
2326
	for _, c := range commits {
2327
		if c.Hash == "" || c.ShortHash == "" || c.Author == "" {
2328
			t.Errorf("empty fields: %+v", c)
2329
		}
2330
	}
2331
}
2332
2333
func TestGit_GetLog_Pagination(t *testing.T) {
2334
	srv := testServer(t)
2335
	git := srv.repos["testrepo"].Git
2336
	hash, _ := git.resolveRef(git.getDefaultBranch())
2337
2338
	commits, hasMore, _ := git.getLog(hash, 0, 1)
2339
	if len(commits) != 1 {
2340
		t.Fatalf("expected 1, got %d", len(commits))
2341
	}
2342
	if !hasMore {
2343
		t.Error("expected hasMore")
2344
	}
2345
}
2346
2347
func TestGit_GetCommit(t *testing.T) {
2348
	srv := testServer(t)
2349
	git := srv.repos["testrepo"].Git
2350
	hash, _ := git.resolveRef(git.getDefaultBranch())
2351
2352
	commit, err := git.getCommit(hash)
2353
	if err != nil {
2354
		t.Fatalf("getCommit: %v", err)
2355
	}
2356
	if commit.Hash != hash {
2357
		t.Errorf("hash mismatch")
2358
	}
2359
	if commit.Author != "Test User" {
2360
		t.Errorf("author: %q", commit.Author)
2361
	}
2362
	if commit.AuthorEmail != "test@example.com" {
2363
		t.Errorf("email: %q", commit.AuthorEmail)
2364
	}
2365
	if commit.AuthorDate.IsZero() {
2366
		t.Error("zero date")
2367
	}
2368
	if len(commit.Parents) == 0 {
2369
		t.Error("expected parents")
2370
	}
2371
}
2372
2373
func TestGit_GetCommit_RootHasNoParents(t *testing.T) {
2374
	srv := testServer(t)
2375
	git := srv.repos["testrepo"].Git
2376
	hash := initialHash(t, srv)
2377
	commit, err := git.getCommit(hash)
2378
	if err != nil {
2379
		t.Fatalf("getCommit: %v", err)
2380
	}
2381
	if len(commit.Parents) != 0 {
2382
		t.Errorf("root commit should have 0 parents, got %d", len(commit.Parents))
2383
	}
2384
}
2385
2386
func TestGit_GetCommit_Invalid(t *testing.T) {
2387
	srv := testServer(t)
2388
	git := srv.repos["testrepo"].Git
2389
	if _, err := git.getCommit("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); err == nil {
2390
		t.Error("expected error")
2391
	}
2392
}
2393
2394
func TestGit_GetDiff(t *testing.T) {
2395
	srv := testServer(t)
2396
	git := srv.repos["testrepo"].Git
2397
	hash, _ := git.resolveRef(git.getDefaultBranch())
2398
2399
	files, err := git.getDiff(hash)
2400
	if err != nil {
2401
		t.Fatalf("getDiff: %v", err)
2402
	}
2403
	if len(files) == 0 {
2404
		t.Fatal("expected files")
2405
	}
2406
	found := false
2407
	for _, f := range files {
2408
		if f.NewName == "extra.txt" {
2409
			found = true
2410
			if f.Status != "A" {
2411
				t.Errorf("status: %q", f.Status)
2412
			}
2413
		}
2414
	}
2415
	if !found {
2416
		t.Error("expected extra.txt")
2417
	}
2418
}
2419
2420
func TestGit_GetDiff_RootCommit(t *testing.T) {
2421
	srv := testServer(t)
2422
	git := srv.repos["testrepo"].Git
2423
	hash := initialHash(t, srv)
2424
2425
	files, err := git.getDiff(hash)
2426
	if err != nil {
2427
		t.Fatalf("getDiff root: %v", err)
2428
	}
2429
	if len(files) == 0 {
2430
		t.Fatal("expected files")
2431
	}
2432
	names := make(map[string]bool)
2433
	for _, f := range files {
2434
		names[f.NewName] = true
2435
	}
2436
	for _, want := range []string{"README.md", "main.go"} {
2437
		if !names[want] {
2438
			t.Errorf("expected %q", want)
2439
		}
2440
	}
2441
}
2442
2443
// ===================================================================
2444
// parseRefLines / parseTagRefLines
2445
// ===================================================================
2446
2447
func TestParseRefLines(t *testing.T) {
2448
	input := "main\tabc123\tabc12345\tSubject\tAuthor\t2024-01-15T10:00:00+00:00\n"
2449
	refs := parseRefLines(input, false)
2450
	if len(refs) != 1 {
2451
		t.Fatalf("expected 1, got %d", len(refs))
2452
	}
2453
	if refs[0].Name != "main" || refs[0].IsTag {
2454
		t.Errorf("got %+v", refs[0])
2455
	}
2456
}
2457
2458
func TestParseRefLines_TruncatesShortHash(t *testing.T) {
2459
	input := "main\tabc123456789\tabc123456789\tSub\tAuth\t2024-01-15T10:00:00+00:00\n"
2460
	refs := parseRefLines(input, false)
2461
	if len(refs[0].ShortHash) != 8 {
2462
		t.Errorf("short hash not truncated: %q", refs[0].ShortHash)
2463
	}
2464
}
2465
2466
func TestParseRefLines_Empty(t *testing.T) {
2467
	if refs := parseRefLines("", false); len(refs) != 0 {
2468
		t.Errorf("expected 0, got %d", len(refs))
2469
	}
2470
}
2471
2472
func TestParseRefLines_Malformed(t *testing.T) {
2473
	if refs := parseRefLines("only\ttwo\tfields\n", false); len(refs) != 0 {
2474
		t.Errorf("expected 0, got %d", len(refs))
2475
	}
2476
}
2477
2478
func TestParseTagRefLines(t *testing.T) {
2479
	input := "v1.0\tabc123\tabc12345\t\tRelease\tAuthor\t2024-01-15T10:00:00+00:00\n"
2480
	refs := parseTagRefLines(input)
2481
	if len(refs) != 1 {
2482
		t.Fatalf("expected 1, got %d", len(refs))
2483
	}
2484
	if !refs[0].IsTag || refs[0].Name != "v1.0" {
2485
		t.Errorf("got %+v", refs[0])
2486
	}
2487
}
2488
2489
func TestParseTagRefLines_Annotated(t *testing.T) {
2490
	input := "v2.0\ttagobj1234567890\ttagobj12\tcommitabcdef1234567890\tRelease\tAuth\t2024-01-15T10:00:00+00:00\n"
2491
	refs := parseTagRefLines(input)
2492
	if len(refs) != 1 {
2493
		t.Fatalf("expected 1, got %d", len(refs))
2494
	}
2495
	if refs[0].Hash != "commitabcdef1234567890" {
2496
		t.Errorf("hash should be deref: %q", refs[0].Hash)
2497
	}
2498
	if refs[0].ShortHash != "commitab" {
2499
		t.Errorf("short hash: %q", refs[0].ShortHash)
2500
	}
2501
}
2502
2503
func TestParseTagRefLines_Empty(t *testing.T) {
2504
	if refs := parseTagRefLines(""); len(refs) != 0 {
2505
		t.Errorf("expected 0, got %d", len(refs))
2506
	}
2507
}
2508
2509
// ===================================================================
2510
// newPageData
2511
// ===================================================================
2512
2513
func TestNewPageData_NoRepo(t *testing.T) {
2514
	srv := testServer(t)
2515
	req := httptest.NewRequest(http.MethodGet, "/", nil)
2516
	pd := srv.newPageData(req, nil, "home", "main")
2517
	if pd.SiteTitle != "Test Forge" {
2518
		t.Errorf("title: %q", pd.SiteTitle)
2519
	}
2520
	if pd.Repo != "" {
2521
		t.Errorf("repo: %q", pd.Repo)
2522
	}
2523
	if pd.Section != "home" {
2524
		t.Errorf("section: %q", pd.Section)
2525
	}
2526
	if pd.Ref != "main" {
2527
		t.Errorf("ref: %q", pd.Ref)
2528
	}
2529
	if pd.SiteDescription != "test site description" {
2530
		t.Errorf("site description: %q", pd.SiteDescription)
2531
	}
2532
}
2533
2534
func TestNewPageData_WithRepo(t *testing.T) {
2535
	srv := testServer(t)
2536
	req := httptest.NewRequest(http.MethodGet, "/testrepo/", nil)
2537
	pd := srv.newPageData(req, srv.repos["testrepo"], "home", "main")
2538
	if pd.Repo != "testrepo" {
2539
		t.Errorf("repo: %q", pd.Repo)
2540
	}
2541
	if pd.Description != "A test repository" {
2542
		t.Errorf("description: %q", pd.Description)
2543
	}
2544
}
2545
2546
func TestNewPageData_WithSession(t *testing.T) {
2547
	srv := testServer(t)
2548
	upsertAvatar(srv.db, "alice", "https://example.com/av.png")
2549
	req := httptest.NewRequest(http.MethodGet, "/", nil)
2550
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("alice")})
2551
	pd := srv.newPageData(req, nil, "", "")
2552
	if pd.Handle != "alice" {
2553
		t.Errorf("handle: %q", pd.Handle)
2554
	}
2555
	if pd.Avatar != "https://example.com/av.png" {
2556
		t.Errorf("avatar: %q", pd.Avatar)
2557
	}
2558
}
2559
2560
func TestNewPageData_NoSession(t *testing.T) {
2561
	srv := testServer(t)
2562
	req := httptest.NewRequest(http.MethodGet, "/", nil)
2563
	pd := srv.newPageData(req, nil, "", "")
2564
	if pd.Handle != "" || pd.Avatar != "" {
2565
		t.Errorf("expected empty handle/avatar, got %q/%q", pd.Handle, pd.Avatar)
2566
	}
2567
}