handler_test.go 72.1 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
		sshClonePrefix: "git@code.cloudhead.io:cloudhead/",
101
		scanPath:       tmpDir,
102
		gitHTTPSlots:   make(chan struct{}, gitHTTPMaxConcurrent),
103
	}
104
}
105
106
func gitRun(t *testing.T, dir string, args ...string) {
107
	t.Helper()
108
	cmd := exec.Command("git", args...)
109
	if dir != "" {
110
		cmd.Dir = dir
111
	}
112
	cmd.Env = append(os.Environ(),
113
		"GIT_CONFIG_GLOBAL=/dev/null",
114
		"GIT_CONFIG_SYSTEM=/dev/null",
115
	)
116
	out, err := cmd.CombinedOutput()
117
	if err != nil {
118
		t.Fatalf("git %v (dir=%s): %v\n%s", args, dir, err, out)
119
	}
120
}
121
122
// get sends a GET through the route dispatcher.
123
func get(t *testing.T, srv *server, path string) *httptest.ResponseRecorder {
124
	t.Helper()
125
	req := httptest.NewRequest(http.MethodGet, path, nil)
126
	w := httptest.NewRecorder()
127
	srv.route(w, req)
128
	return w
129
}
130
131
// getWithCookie sends a GET with a session cookie.
132
func getWithCookie(t *testing.T, srv *server, path, handle string) *httptest.ResponseRecorder {
133
	t.Helper()
134
	req := httptest.NewRequest(http.MethodGet, path, nil)
135
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)})
136
	w := httptest.NewRecorder()
137
	srv.route(w, req)
138
	return w
139
}
140
141
// postForm sends a POST with form data through the route dispatcher.
142
func postForm(t *testing.T, srv *server, path string, form url.Values) *httptest.ResponseRecorder {
143
	t.Helper()
144
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
145
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
146
	w := httptest.NewRecorder()
147
	srv.route(w, req)
148
	return w
149
}
150
151
// postFormWithCookie sends a POST with form data and a session cookie.
152
func postFormWithCookie(t *testing.T, srv *server, path string, form url.Values, handle string) *httptest.ResponseRecorder {
153
	t.Helper()
154
	req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
155
	req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
156
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession(handle)})
157
	w := httptest.NewRecorder()
158
	srv.route(w, req)
159
	return w
160
}
161
162
// getMux sends a GET through the full mux (including static asset routes).
163
func getMux(t *testing.T, srv *server, path string) *httptest.ResponseRecorder {
164
	t.Helper()
165
	mux := http.NewServeMux()
166
	mux.HandleFunc("/style.css", srv.serveCSS)
167
	mux.HandleFunc("/radiant.svg", srv.serveLogo)
168
	mux.HandleFunc("/fonts/", srv.serveFont)
169
	mux.HandleFunc("/", srv.route)
170
	req := httptest.NewRequest(http.MethodGet, path, nil)
171
	w := httptest.NewRecorder()
172
	mux.ServeHTTP(w, req)
173
	return w
174
}
175
176
// defaultRef returns the default branch name for the test repo.
177
func defaultRef(t *testing.T, srv *server) string {
178
	t.Helper()
179
	return srv.repos["testrepo"].Git.getDefaultBranch()
180
}
181
182
// latestHash returns the latest commit hash on the default branch.
183
func latestHash(t *testing.T, srv *server) string {
184
	t.Helper()
185
	git := srv.repos["testrepo"].Git
186
	h, err := git.resolveRef(git.getDefaultBranch())
187
	if err != nil {
188
		t.Fatalf("resolveRef: %v", err)
189
	}
190
	return h
191
}
192
193
// initialHash returns the hash of the "Initial commit".
194
func initialHash(t *testing.T, srv *server) string {
195
	t.Helper()
196
	git := srv.repos["testrepo"].Git
197
	h, _ := git.resolveRef(git.getDefaultBranch())
198
	commits, _, _ := git.getLog(h, 0, 50)
199
	for _, c := range commits {
200
		if c.Subject == "Initial commit" {
201
			return c.Hash
202
		}
203
	}
204
	t.Fatal("initial commit not found")
205
	return ""
206
}
207
208
func assertCode(t *testing.T, w *httptest.ResponseRecorder, want int) {
209
	t.Helper()
210
	if w.Code != want {
211
		n := w.Body.Len()
212
		if n > 500 {
213
			n = 500
214
		}
215
		t.Fatalf("expected status %d, got %d\nbody: %s", want, w.Code, w.Body.String()[:n])
216
	}
217
}
218
219
func assertContains(t *testing.T, w *httptest.ResponseRecorder, substr string) {
220
	t.Helper()
221
	if !strings.Contains(w.Body.String(), substr) {
222
		n := w.Body.Len()
223
		if n > 1000 {
224
			n = 1000
225
		}
226
		t.Errorf("expected body to contain %q\nbody: %s", substr, w.Body.String()[:n])
227
	}
228
}
229
230
func assertNotContains(t *testing.T, w *httptest.ResponseRecorder, substr string) {
231
	t.Helper()
232
	if strings.Contains(w.Body.String(), substr) {
233
		t.Errorf("expected body NOT to contain %q", substr)
234
	}
235
}
236
237
func assertHeader(t *testing.T, w *httptest.ResponseRecorder, key, wantSubstr string) {
238
	t.Helper()
239
	got := w.Header().Get(key)
240
	if !strings.Contains(got, wantSubstr) {
241
		t.Errorf("expected header %s to contain %q, got %q", key, wantSubstr, got)
242
	}
243
}
244
245
func assertRedirect(t *testing.T, w *httptest.ResponseRecorder, code int, locSubstr string) {
246
	t.Helper()
247
	assertCode(t, w, code)
248
	loc := w.Header().Get("Location")
249
	if !strings.Contains(loc, locSubstr) {
250
		t.Errorf("expected Location to contain %q, got %q", locSubstr, loc)
251
	}
252
}
253
254
// ===================================================================
255
// Static assets
256
// ===================================================================
257
258
func TestServeCSS(t *testing.T) {
259
	srv := testServer(t)
260
	w := getMux(t, srv, "/style.css")
261
	assertCode(t, w, 200)
262
	assertHeader(t, w, "Content-Type", "text/css")
263
	assertHeader(t, w, "Cache-Control", "public")
264
	if w.Body.Len() == 0 {
265
		t.Error("empty CSS body")
266
	}
267
}
268
269
func TestServeLogo(t *testing.T) {
270
	srv := testServer(t)
271
	w := getMux(t, srv, "/radiant.svg")
272
	assertCode(t, w, 200)
273
	assertHeader(t, w, "Content-Type", "image/svg+xml")
274
	assertHeader(t, w, "Cache-Control", "public")
275
	if w.Body.Len() == 0 {
276
		t.Error("empty logo body")
277
	}
278
}
279
280
func TestServeFont_Known(t *testing.T) {
281
	srv := testServer(t)
282
	w := getMux(t, srv, "/fonts/RethinkSans.ttf")
283
	assertCode(t, w, 200)
284
	assertHeader(t, w, "Content-Type", "font/ttf")
285
	assertHeader(t, w, "Cache-Control", "public")
286
	if w.Body.Len() == 0 {
287
		t.Error("empty font body")
288
	}
289
}
290
291
func TestServeFont_Unknown(t *testing.T) {
292
	srv := testServer(t)
293
	w := getMux(t, srv, "/fonts/nonexistent.ttf")
294
	assertCode(t, w, 404)
295
}
296
297
// ===================================================================
298
// Index page
299
// ===================================================================
300
301
func TestIndex(t *testing.T) {
302
	srv := testServer(t)
303
	w := get(t, srv, "/")
304
	assertCode(t, w, 200)
305
	assertContains(t, w, "testrepo")
306
	assertContains(t, w, "A test repository")
307
	assertContains(t, w, "Test Forge")
308
	assertContains(t, w, "test site description")
309
	// Footer
310
	assertContains(t, w, "Radiant Forge")
311
}
312
313
func TestIndex_Empty(t *testing.T) {
314
	srv := testServer(t)
315
	srv.repos = map[string]*RepoInfo{}
316
	srv.sorted = nil
317
	w := get(t, srv, "/")
318
	assertCode(t, w, 200)
319
	assertContains(t, w, "No repositories found")
320
}
321
322
func TestIndex_SignInLink(t *testing.T) {
323
	srv := testServer(t)
324
	w := get(t, srv, "/")
325
	assertCode(t, w, 200)
326
	assertContains(t, w, "Sign in")
327
}
328
329
func TestIndex_LoggedInShowsAvatar(t *testing.T) {
330
	srv := testServer(t)
331
	upsertAvatar(srv.db, "alice", "https://example.com/alice.png")
332
	w := getWithCookie(t, srv, "/", "alice")
333
	assertCode(t, w, 200)
334
	assertContains(t, w, "https://example.com/alice.png")
335
	assertContains(t, w, "Sign out")
336
}
337
338
// ===================================================================
339
// Repo summary (home)
340
// ===================================================================
341
342
func TestSummary(t *testing.T) {
343
	srv := testServer(t)
344
	w := get(t, srv, "/testrepo/")
345
	assertCode(t, w, 200)
346
	assertContains(t, w, "testrepo")
347
	assertContains(t, w, "A test repository")
348
	// Navigation tabs
349
	assertContains(t, w, "home")
350
	assertContains(t, w, "log")
351
	assertContains(t, w, "refs")
352
	assertContains(t, w, "discussions")
353
	// File tree
354
	assertContains(t, w, "README.md")
355
	assertContains(t, w, "main.go")
356
	assertContains(t, w, "pkg")
357
	assertContains(t, w, "extra.txt")
358
	// README content
359
	assertContains(t, w, "Hello world")
360
	// Last commit
361
	assertContains(t, w, "Add extra file")
362
	assertContains(t, w, "Test User")
363
	// Clone URLs
364
	assertContains(t, w, "testrepo.git")
365
	// No footer on repo pages
366
	assertNotContains(t, w, "Powered by")
367
}
368
369
func TestSummary_CloneSSH(t *testing.T) {
370
	srv := testServer(t)
371
	repo := srv.repos["testrepo"]
372
	repo.Name = "example"
373
	srv.repos = map[string]*RepoInfo{"example": repo}
374
375
	w := get(t, srv, "/example/")
376
	assertCode(t, w, 200)
377
	assertContains(t, w, "git@code.cloudhead.io:cloudhead/example.git")
378
}
379
380
func TestSummary_NoTrailingSlash(t *testing.T) {
381
	srv := testServer(t)
382
	w := get(t, srv, "/testrepo")
383
	assertCode(t, w, 200)
384
	assertContains(t, w, "testrepo")
385
}
386
387
func TestSummary_BranchSelector(t *testing.T) {
388
	srv := testServer(t)
389
	w := get(t, srv, "/testrepo/")
390
	assertCode(t, w, 200)
391
	assertContains(t, w, "feature/test")
392
}
393
394
func TestSummary_CloneHTTPS_XForwardedProto(t *testing.T) {
395
	srv := testServer(t)
396
	req := httptest.NewRequest(http.MethodGet, "/testrepo/", nil)
397
	req.Header.Set("X-Forwarded-Proto", "https")
398
	w := httptest.NewRecorder()
399
	srv.route(w, req)
400
	assertCode(t, w, 200)
401
	assertContains(t, w, "https://")
402
}
403
404
// ===================================================================
405
// Refs page
406
// ===================================================================
407
408
func TestRefs(t *testing.T) {
409
	srv := testServer(t)
410
	w := get(t, srv, "/testrepo/refs")
411
	assertCode(t, w, 200)
412
	assertContains(t, w, "feature/test")
413
	assertContains(t, w, "v1.0")
414
	assertContains(t, w, "Branch")
415
	assertContains(t, w, "Tag")
416
}
417
418
// ===================================================================
419
// Log page
420
// ===================================================================
421
422
func TestLog_Default(t *testing.T) {
423
	srv := testServer(t)
424
	w := get(t, srv, "/testrepo/log/")
425
	assertCode(t, w, 200)
426
	assertContains(t, w, "Initial commit")
427
	assertContains(t, w, "Add extra file")
428
	assertContains(t, w, "Test User")
429
	assertContains(t, w, "Hash")
430
	assertContains(t, w, "Subject")
431
}
432
433
func TestLog_SpecificRef(t *testing.T) {
434
	srv := testServer(t)
435
	w := get(t, srv, "/testrepo/log/feature/test")
436
	assertCode(t, w, 200)
437
	assertContains(t, w, "Add feature")
438
	assertContains(t, w, "Initial commit")
439
}
440
441
func TestLog_Pagination(t *testing.T) {
442
	srv := testServer(t)
443
	w := get(t, srv, "/testrepo/log/?page=0")
444
	assertCode(t, w, 200)
445
	assertContains(t, w, "Initial commit")
446
}
447
448
func TestLog_HighPage(t *testing.T) {
449
	srv := testServer(t)
450
	ref := defaultRef(t, srv)
451
	w := get(t, srv, "/testrepo/log/"+ref+"?page=999")
452
	assertCode(t, w, 200)
453
	assertContains(t, w, "No commits found")
454
}
455
456
func TestLog_NegativePage(t *testing.T) {
457
	srv := testServer(t)
458
	ref := defaultRef(t, srv)
459
	w := get(t, srv, "/testrepo/log/"+ref+"?page=-1")
460
	assertCode(t, w, 200)
461
	// Negative clamped to 0, should show commits.
462
	assertContains(t, w, "Initial commit")
463
}
464
465
func TestLog_InvalidRef(t *testing.T) {
466
	srv := testServer(t)
467
	w := get(t, srv, "/testrepo/log/nonexistent-ref")
468
	assertCode(t, w, 200)
469
	// Renders the empty log page.
470
}
471
472
func TestLog_BranchSelector(t *testing.T) {
473
	srv := testServer(t)
474
	w := get(t, srv, "/testrepo/log/")
475
	assertCode(t, w, 200)
476
	assertContains(t, w, "feature/test")
477
}
478
479
// ===================================================================
480
// Tree page
481
// ===================================================================
482
483
func TestTree_Root(t *testing.T) {
484
	srv := testServer(t)
485
	ref := defaultRef(t, srv)
486
	w := get(t, srv, "/testrepo/tree/"+ref)
487
	assertCode(t, w, 200)
488
	assertContains(t, w, "main.go")
489
	assertContains(t, w, "pkg")
490
	assertContains(t, w, "README.md")
491
}
492
493
func TestTree_Directory(t *testing.T) {
494
	srv := testServer(t)
495
	ref := defaultRef(t, srv)
496
	w := get(t, srv, "/testrepo/tree/"+ref+"/pkg")
497
	assertCode(t, w, 200)
498
	assertContains(t, w, "lib.go")
499
}
500
501
func TestTree_File(t *testing.T) {
502
	srv := testServer(t)
503
	ref := defaultRef(t, srv)
504
	w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
505
	assertCode(t, w, 200)
506
	assertContains(t, w, `<span class="syntax-keyword">package</span> main`)
507
	assertContains(t, w, `<span class="syntax-keyword">func</span>`)
508
	assertContains(t, w, `class="syntax-punctuation"`)
509
	assertContains(t, w, "main.go")
510
	assertContains(t, w, "/raw/")
511
}
512
513
func TestTree_NestedFile(t *testing.T) {
514
	srv := testServer(t)
515
	ref := defaultRef(t, srv)
516
	w := get(t, srv, "/testrepo/tree/"+ref+"/pkg/lib.go")
517
	assertCode(t, w, 200)
518
	assertContains(t, w, `<span class="syntax-keyword">package</span> pkg`)
519
}
520
521
func TestTree_BranchWithSlash(t *testing.T) {
522
	srv := testServer(t)
523
	w := get(t, srv, "/testrepo/tree/feature/test")
524
	assertCode(t, w, 200)
525
	assertContains(t, w, "feature.txt")
526
}
527
528
func TestTree_BranchWithSlash_File(t *testing.T) {
529
	srv := testServer(t)
530
	w := get(t, srv, "/testrepo/tree/feature/test/feature.txt")
531
	assertCode(t, w, 200)
532
	assertContains(t, w, "feature content")
533
}
534
535
func TestTree_EmptyRest(t *testing.T) {
536
	srv := testServer(t)
537
	w := get(t, srv, "/testrepo/tree/")
538
	assertCode(t, w, 200)
539
	assertContains(t, w, "main.go")
540
}
541
542
func TestTree_InvalidRef(t *testing.T) {
543
	srv := testServer(t)
544
	w := get(t, srv, "/testrepo/tree/nonexistent-ref")
545
	assertCode(t, w, 404)
546
	assertContains(t, w, "Ref not found")
547
}
548
549
func TestTree_FileNotFound(t *testing.T) {
550
	srv := testServer(t)
551
	ref := defaultRef(t, srv)
552
	w := get(t, srv, "/testrepo/tree/"+ref+"/no-such-file.txt")
553
	assertCode(t, w, 404)
554
	assertContains(t, w, "File not found")
555
}
556
557
func TestTree_LineNumbers(t *testing.T) {
558
	srv := testServer(t)
559
	ref := defaultRef(t, srv)
560
	w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
561
	assertCode(t, w, 200)
562
	assertContains(t, w, `id="L1"`)
563
	assertContains(t, w, `href="#L1"`)
564
}
565
566
func TestTree_FileShowsSize(t *testing.T) {
567
	srv := testServer(t)
568
	ref := defaultRef(t, srv)
569
	w := get(t, srv, "/testrepo/tree/"+ref+"/main.go")
570
	assertCode(t, w, 200)
571
	// formatSize should produce "X B" for small files.
572
	assertContains(t, w, " B")
573
}
574
575
// ===================================================================
576
// Commit page
577
// ===================================================================
578
579
func TestCommit(t *testing.T) {
580
	srv := testServer(t)
581
	hash := latestHash(t, srv)
582
	w := get(t, srv, "/testrepo/commit/"+hash)
583
	assertCode(t, w, 200)
584
	assertContains(t, w, "Add extra file")
585
	assertContains(t, w, "Test User")
586
	assertContains(t, w, hash)
587
	assertContains(t, w, "extra.txt")
588
	assertContains(t, w, "parent")
589
}
590
591
func TestCommit_Initial(t *testing.T) {
592
	srv := testServer(t)
593
	hash := initialHash(t, srv)
594
	w := get(t, srv, "/testrepo/commit/"+hash)
595
	assertCode(t, w, 200)
596
	assertContains(t, w, "Initial commit")
597
	assertContains(t, w, "README.md")
598
	assertContains(t, w, "main.go")
599
}
600
601
func TestCommit_NotFound(t *testing.T) {
602
	srv := testServer(t)
603
	w := get(t, srv, "/testrepo/commit/deadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
604
	assertCode(t, w, 404)
605
	assertContains(t, w, "Commit not found")
606
}
607
608
func TestCommit_CommitHashInNav(t *testing.T) {
609
	srv := testServer(t)
610
	hash := latestHash(t, srv)
611
	w := get(t, srv, "/testrepo/commit/"+hash)
612
	assertCode(t, w, 200)
613
	assertContains(t, w, hash[:8])
614
}
615
616
func TestCommit_DiffStats(t *testing.T) {
617
	srv := testServer(t)
618
	hash := latestHash(t, srv)
619
	w := get(t, srv, "/testrepo/commit/"+hash)
620
	assertCode(t, w, 200)
621
	// Diff stats: +N -N
622
	assertContains(t, w, "added")
623
}
624
625
// ===================================================================
626
// Raw file download
627
// ===================================================================
628
629
func TestRaw(t *testing.T) {
630
	srv := testServer(t)
631
	ref := defaultRef(t, srv)
632
	w := get(t, srv, "/testrepo/raw/"+ref+"/main.go")
633
	assertCode(t, w, 200)
634
	assertHeader(t, w, "Content-Type", "text/plain")
635
	if !strings.Contains(w.Body.String(), "package main") {
636
		t.Error("expected raw file content")
637
	}
638
	cl := w.Header().Get("Content-Length")
639
	if cl == "" || cl == "0" {
640
		t.Error("expected non-zero Content-Length")
641
	}
642
}
643
644
func TestRaw_Markdown(t *testing.T) {
645
	srv := testServer(t)
646
	ref := defaultRef(t, srv)
647
	w := get(t, srv, "/testrepo/raw/"+ref+"/README.md")
648
	assertCode(t, w, 200)
649
	assertHeader(t, w, "Content-Type", "text/plain")
650
	assertContains(t, w, "# Test Repo")
651
}
652
653
func TestRaw_NoPath(t *testing.T) {
654
	srv := testServer(t)
655
	w := get(t, srv, "/testrepo/raw/")
656
	assertCode(t, w, 404)
657
	assertContains(t, w, "No path specified")
658
}
659
660
func TestRaw_RefOnly(t *testing.T) {
661
	srv := testServer(t)
662
	ref := defaultRef(t, srv)
663
	w := get(t, srv, "/testrepo/raw/"+ref)
664
	assertCode(t, w, 404)
665
	assertContains(t, w, "No file path")
666
}
667
668
func TestRaw_InvalidRef(t *testing.T) {
669
	srv := testServer(t)
670
	w := get(t, srv, "/testrepo/raw/nonexistent/file.txt")
671
	assertCode(t, w, 404)
672
	assertContains(t, w, "Ref not found")
673
}
674
675
func TestRaw_FileNotFound(t *testing.T) {
676
	srv := testServer(t)
677
	ref := defaultRef(t, srv)
678
	w := get(t, srv, "/testrepo/raw/"+ref+"/no-such-file.txt")
679
	assertCode(t, w, 404)
680
	assertContains(t, w, "File not found")
681
}
682
683
// ===================================================================
684
// Routing: repo not found, unknown action, .git suffix
685
// ===================================================================
686
687
func TestRoute_RepoNotFound(t *testing.T) {
688
	srv := testServer(t)
689
	w := get(t, srv, "/nonexistent/")
690
	assertCode(t, w, 404)
691
	assertContains(t, w, "Repository not found")
692
}
693
694
func TestRoute_UnknownAction(t *testing.T) {
695
	srv := testServer(t)
696
	w := get(t, srv, "/testrepo/foobar")
697
	assertCode(t, w, 404)
698
	assertContains(t, w, "Page not found")
699
}
700
701
func TestRoute_RepoNameDotGit(t *testing.T) {
702
	srv := testServer(t)
703
	w := get(t, srv, "/testrepo.git/refs")
704
	assertCode(t, w, 200)
705
	assertContains(t, w, "feature/test")
706
}
707
708
// ===================================================================
709
// Base URL prefix
710
// ===================================================================
711
712
func TestBaseURL_Index(t *testing.T) {
713
	srv := testServer(t)
714
	srv.baseURL = "/git"
715
	req := httptest.NewRequest(http.MethodGet, "/git/", nil)
716
	w := httptest.NewRecorder()
717
	srv.route(w, req)
718
	assertCode(t, w, 200)
719
	assertContains(t, w, "/git/testrepo/")
720
}
721
722
func TestBaseURL_Summary(t *testing.T) {
723
	srv := testServer(t)
724
	srv.baseURL = "/git"
725
	req := httptest.NewRequest(http.MethodGet, "/git/testrepo/", nil)
726
	w := httptest.NewRecorder()
727
	srv.route(w, req)
728
	assertCode(t, w, 200)
729
	assertContains(t, w, "/git/testrepo/")
730
}
731
732
func TestBaseURL_LinksInRepoPages(t *testing.T) {
733
	srv := testServer(t)
734
	srv.baseURL = "/code"
735
	req := httptest.NewRequest(http.MethodGet, "/code/testrepo/refs", nil)
736
	w := httptest.NewRecorder()
737
	srv.route(w, req)
738
	assertCode(t, w, 200)
739
	assertContains(t, w, "/code/testrepo/log/")
740
	assertContains(t, w, "/code/testrepo/commit/")
741
}
742
743
// ===================================================================
744
// Basic auth
745
// ===================================================================
746
747
func TestBasicAuth_NoCredentials(t *testing.T) {
748
	srv := testServer(t)
749
	srv.username = "admin"
750
	srv.password = "secret"
751
	mux := http.NewServeMux()
752
	mux.HandleFunc("/", srv.route)
753
	handler := srv.basicAuth(mux)
754
755
	req := httptest.NewRequest(http.MethodGet, "/", nil)
756
	w := httptest.NewRecorder()
757
	handler.ServeHTTP(w, req)
758
	assertCode(t, w, 401)
759
	assertHeader(t, w, "WWW-Authenticate", "Basic")
760
}
761
762
func TestBasicAuth_WrongCredentials(t *testing.T) {
763
	srv := testServer(t)
764
	srv.username = "admin"
765
	srv.password = "secret"
766
	mux := http.NewServeMux()
767
	mux.HandleFunc("/", srv.route)
768
	handler := srv.basicAuth(mux)
769
770
	req := httptest.NewRequest(http.MethodGet, "/", nil)
771
	req.SetBasicAuth("admin", "wrong")
772
	w := httptest.NewRecorder()
773
	handler.ServeHTTP(w, req)
774
	assertCode(t, w, 401)
775
}
776
777
func TestBasicAuth_CorrectCredentials(t *testing.T) {
778
	srv := testServer(t)
779
	srv.username = "admin"
780
	srv.password = "secret"
781
	mux := http.NewServeMux()
782
	mux.HandleFunc("/", srv.route)
783
	handler := srv.basicAuth(mux)
784
785
	req := httptest.NewRequest(http.MethodGet, "/", nil)
786
	req.SetBasicAuth("admin", "secret")
787
	w := httptest.NewRecorder()
788
	handler.ServeHTTP(w, req)
789
	assertCode(t, w, 200)
790
}
791
792
// ===================================================================
793
// Error page
794
// ===================================================================
795
796
func TestRenderError(t *testing.T) {
797
	srv := testServer(t)
798
	req := httptest.NewRequest(http.MethodGet, "/bad-path", nil)
799
	w := httptest.NewRecorder()
800
	srv.renderError(w, req, 404, "Something went wrong")
801
	assertCode(t, w, 404)
802
	assertContains(t, w, "Something went wrong")
803
	assertContains(t, w, "404")
804
	assertContains(t, w, "/bad-path")
805
	assertContains(t, w, "Back to index")
806
}
807
808
// ===================================================================
809
// Discussions
810
// ===================================================================
811
812
func TestDiscussions_ListEmpty(t *testing.T) {
813
	srv := testServer(t)
814
	w := get(t, srv, "/testrepo/discussions")
815
	assertCode(t, w, 200)
816
	assertContains(t, w, "No discussions yet")
817
	assertContains(t, w, "discussions")
818
}
819
820
func TestDiscussions_ListWithItems(t *testing.T) {
821
	srv := testServer(t)
822
	createDiscussion(srv.db, "testrepo", "First Thread", "body1", "alice")
823
	createDiscussion(srv.db, "testrepo", "Second Thread", "body2", "bob")
824
825
	w := get(t, srv, "/testrepo/discussions")
826
	assertCode(t, w, 200)
827
	assertContains(t, w, "First Thread")
828
	assertContains(t, w, "Second Thread")
829
	assertContains(t, w, "alice")
830
	assertContains(t, w, "bob")
831
	assertNotContains(t, w, "No discussions yet")
832
}
833
834
func TestDiscussions_ListShowsReplyCount(t *testing.T) {
835
	srv := testServer(t)
836
	id, _ := createDiscussion(srv.db, "testrepo", "With Replies", "", "alice")
837
	createReply(srv.db, int(id), "reply 1", "bob")
838
	createReply(srv.db, int(id), "reply 2", "carol")
839
840
	w := get(t, srv, "/testrepo/discussions")
841
	assertCode(t, w, 200)
842
	assertContains(t, w, ">2<")
843
}
844
845
func TestDiscussions_ListOnlyShowsCurrentRepo(t *testing.T) {
846
	srv := testServer(t)
847
	createDiscussion(srv.db, "testrepo", "Mine", "", "alice")
848
	createDiscussion(srv.db, "other", "Not Mine", "", "bob")
849
850
	w := get(t, srv, "/testrepo/discussions")
851
	assertCode(t, w, 200)
852
	assertContains(t, w, "Mine")
853
	assertNotContains(t, w, "Not Mine")
854
}
855
856
func TestDiscussions_ListShowsNewButtonWhenLoggedIn(t *testing.T) {
857
	srv := testServer(t)
858
	w := getWithCookie(t, srv, "/testrepo/discussions", "alice")
859
	assertCode(t, w, 200)
860
	assertContains(t, w, "New discussion")
861
}
862
863
func TestDiscussions_ListHidesNewButtonWhenLoggedOut(t *testing.T) {
864
	srv := testServer(t)
865
	w := get(t, srv, "/testrepo/discussions")
866
	assertCode(t, w, 200)
867
	assertNotContains(t, w, "New discussion")
868
}
869
870
func TestDiscussions_NewRedirectsWhenNotLoggedIn(t *testing.T) {
871
	srv := testServer(t)
872
	w := get(t, srv, "/testrepo/discussions/new")
873
	assertRedirect(t, w, 303, "login")
874
}
875
876
func TestDiscussions_NewPageWhenLoggedIn(t *testing.T) {
877
	srv := testServer(t)
878
	w := getWithCookie(t, srv, "/testrepo/discussions/new", "alice")
879
	assertCode(t, w, 200)
880
	assertContains(t, w, "New discussion")
881
	assertContains(t, w, "Title")
882
	assertContains(t, w, "Create discussion")
883
}
884
885
func TestDiscussions_CreateSuccess(t *testing.T) {
886
	srv := testServer(t)
887
	form := url.Values{"title": {"My Discussion"}, "body": {"Discussion body."}}
888
	w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice")
889
	assertRedirect(t, w, 303, "/testrepo/discussions/")
890
891
	// Verify persisted.
892
	list := listDiscussions(srv.db, "testrepo")
893
	if len(list) != 1 {
894
		t.Fatalf("expected 1, got %d", len(list))
895
	}
896
	if list[0].Title != "My Discussion" {
897
		t.Errorf("title: got %q", list[0].Title)
898
	}
899
	if list[0].Author != "alice" {
900
		t.Errorf("author: got %q", list[0].Author)
901
	}
902
}
903
904
func TestDiscussions_CreateEmptyTitle(t *testing.T) {
905
	srv := testServer(t)
906
	form := url.Values{"title": {""}, "body": {"some body"}}
907
	w := postFormWithCookie(t, srv, "/testrepo/discussions/new", form, "alice")
908
	assertCode(t, w, 200)
909
	assertContains(t, w, "Title is required")
910
}
911
912
func TestDiscussions_CreateNotLoggedIn(t *testing.T) {
913
	srv := testServer(t)
914
	form := url.Values{"title": {"Title"}, "body": {"body"}}
915
	w := postForm(t, srv, "/testrepo/discussions/new", form)
916
	assertRedirect(t, w, 303, "login")
917
}
918
919
func TestDiscussions_ViewDiscussion(t *testing.T) {
920
	srv := testServer(t)
921
	id, _ := createDiscussion(srv.db, "testrepo", "View Me", "Look at this body", "alice")
922
	createReply(srv.db, int(id), "A thoughtful reply", "bob")
923
924
	w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
925
	assertCode(t, w, 200)
926
	assertContains(t, w, "View Me")
927
	assertContains(t, w, "Look at this body")
928
	assertContains(t, w, "alice")
929
	assertContains(t, w, "A thoughtful reply")
930
	assertContains(t, w, "bob")
931
	assertContains(t, w, "Sign in")
932
}
933
934
func TestDiscussions_ViewDiscussionLoggedIn(t *testing.T) {
935
	srv := testServer(t)
936
	id, _ := createDiscussion(srv.db, "testrepo", "Logged In View", "", "alice")
937
938
	w := getWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), "bob")
939
	assertCode(t, w, 200)
940
	assertContains(t, w, "Reply as")
941
	assertContains(t, w, "bob")
942
	assertContains(t, w, "Post reply")
943
}
944
945
func TestDiscussions_ViewNotFound(t *testing.T) {
946
	srv := testServer(t)
947
	w := get(t, srv, "/testrepo/discussions/99999")
948
	assertCode(t, w, 404)
949
	assertContains(t, w, "Discussion not found")
950
}
951
952
func TestDiscussions_ViewInvalidID(t *testing.T) {
953
	srv := testServer(t)
954
	w := get(t, srv, "/testrepo/discussions/abc")
955
	assertCode(t, w, 404)
956
	assertContains(t, w, "Discussion not found")
957
}
958
959
func TestDiscussions_ViewWrongRepo(t *testing.T) {
960
	srv := testServer(t)
961
	id, _ := createDiscussion(srv.db, "otherrepo", "Wrong Repo", "", "alice")
962
	w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
963
	assertCode(t, w, 404)
964
	assertContains(t, w, "Discussion not found")
965
}
966
967
func TestDiscussions_Reply(t *testing.T) {
968
	srv := testServer(t)
969
	id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
970
971
	form := url.Values{"body": {"My reply text"}}
972
	w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob")
973
	assertRedirect(t, w, 303, fmt.Sprintf("/testrepo/discussions/%d#reply-", id))
974
975
	replies := getReplies(srv.db, int(id))
976
	if len(replies) != 1 {
977
		t.Fatalf("expected 1 reply, got %d", len(replies))
978
	}
979
	if replies[0].Body != "My reply text" {
980
		t.Errorf("body: got %q", replies[0].Body)
981
	}
982
	if replies[0].Author != "bob" {
983
		t.Errorf("author: got %q", replies[0].Author)
984
	}
985
}
986
987
func TestDiscussions_ReplyEmptyBody(t *testing.T) {
988
	srv := testServer(t)
989
	id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
990
991
	form := url.Values{"body": {""}}
992
	w := postFormWithCookie(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form, "bob")
993
	assertCode(t, w, 200)
994
	assertContains(t, w, "Reply cannot be empty")
995
}
996
997
func TestDiscussions_ReplyNotLoggedIn(t *testing.T) {
998
	srv := testServer(t)
999
	id, _ := createDiscussion(srv.db, "testrepo", "Reply Target", "", "alice")
1000
1001
	form := url.Values{"body": {"attempt"}}
1002
	w := postForm(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id), form)
1003
	assertCode(t, w, 403)
1004
	assertContains(t, w, "must be signed in")
1005
}
1006
1007
// ===================================================================
1008
// Login / Logout
1009
// ===================================================================
1010
1011
func TestLogin_Page(t *testing.T) {
1012
	srv := testServer(t)
1013
	w := get(t, srv, "/login")
1014
	assertCode(t, w, 200)
1015
	assertContains(t, w, "Sign in with")
1016
	assertContains(t, w, "Bluesky")
1017
	assertContains(t, w, "handle")
1018
	assertContains(t, w, "app_password")
1019
}
1020
1021
func TestLogin_PageWithReturn(t *testing.T) {
1022
	srv := testServer(t)
1023
	w := get(t, srv, "/login?return=/testrepo/discussions")
1024
	assertCode(t, w, 200)
1025
	assertContains(t, w, "/testrepo/discussions")
1026
}
1027
1028
func TestLogin_PostMissingFields(t *testing.T) {
1029
	srv := testServer(t)
1030
	form := url.Values{"handle": {""}, "app_password": {""}}
1031
	w := postForm(t, srv, "/login", form)
1032
	assertCode(t, w, 200)
1033
	assertContains(t, w, "Handle and app password are required")
1034
}
1035
1036
func TestLogin_PostMissingPassword(t *testing.T) {
1037
	srv := testServer(t)
1038
	form := url.Values{"handle": {"alice.bsky.social"}, "app_password": {""}}
1039
	w := postForm(t, srv, "/login", form)
1040
	assertCode(t, w, 200)
1041
	assertContains(t, w, "Handle and app password are required")
1042
}
1043
1044
func TestLogout(t *testing.T) {
1045
	srv := testServer(t)
1046
	req := httptest.NewRequest(http.MethodGet, "/logout", nil)
1047
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("alice")})
1048
	w := httptest.NewRecorder()
1049
	srv.route(w, req)
1050
	assertRedirect(t, w, 303, "/")
1051
1052
	found := false
1053
	for _, c := range w.Result().Cookies() {
1054
		if c.Name == sessionCookieName && c.MaxAge < 0 {
1055
			found = true
1056
		}
1057
	}
1058
	if !found {
1059
		t.Error("expected session cookie to be cleared")
1060
	}
1061
}
1062
1063
func TestLogout_WithReturn(t *testing.T) {
1064
	srv := testServer(t)
1065
	req := httptest.NewRequest(http.MethodGet, "/logout?return=/testrepo/", nil)
1066
	w := httptest.NewRecorder()
1067
	srv.route(w, req)
1068
	assertRedirect(t, w, 303, "/testrepo/")
1069
}
1070
1071
func TestLogout_DefaultReturn(t *testing.T) {
1072
	srv := testServer(t)
1073
	req := httptest.NewRequest(http.MethodGet, "/logout", nil)
1074
	w := httptest.NewRecorder()
1075
	srv.route(w, req)
1076
	assertRedirect(t, w, 303, "/")
1077
}
1078
1079
// ===================================================================
1080
// isGitHTTPRequest
1081
// ===================================================================
1082
1083
func TestGitHTTPEnvLimitsReceivePackMemory(t *testing.T) {
1084
	env := gitHTTPEnv()
1085
	values := make(map[string]string, len(env))
1086
	for _, entry := range env {
1087
		key, value, ok := strings.Cut(entry, "=")
1088
		if !ok {
1089
			t.Fatalf("invalid environment entry %q", entry)
1090
		}
1091
		values[key] = value
1092
	}
1093
1094
	if got := values["GIT_CONFIG_COUNT"]; got != "3" {
1095
		t.Fatalf("GIT_CONFIG_COUNT = %q, want 3", got)
1096
	}
1097
	if got := values["GIT_CONFIG_KEY_1"]; got != "pack.threads" {
1098
		t.Errorf("GIT_CONFIG_KEY_1 = %q, want pack.threads", got)
1099
	}
1100
	if got := values["GIT_CONFIG_VALUE_1"]; got != "1" {
1101
		t.Errorf("GIT_CONFIG_VALUE_1 = %q, want 1", got)
1102
	}
1103
	if got := values["GIT_CONFIG_KEY_2"]; got != "core.deltaBaseCacheLimit" {
1104
		t.Errorf("GIT_CONFIG_KEY_2 = %q, want core.deltaBaseCacheLimit", got)
1105
	}
1106
	if got := values["GIT_CONFIG_VALUE_2"]; got != gitHTTPDeltaBaseCacheLimit {
1107
		t.Errorf("GIT_CONFIG_VALUE_2 = %q, want %s", got, gitHTTPDeltaBaseCacheLimit)
1108
	}
1109
}
1110
1111
func TestGitHTTPRejectsExcessConcurrency(t *testing.T) {
1112
	srv := testServer(t)
1113
	for range gitHTTPMaxConcurrent {
1114
		srv.gitHTTPSlots <- struct{}{}
1115
	}
1116
1117
	req := httptest.NewRequest(http.MethodPost, "/testrepo/git-receive-pack", nil)
1118
	w := httptest.NewRecorder()
1119
	srv.handleGitHTTP(w, req, srv.repos["testrepo"])
1120
1121
	if w.Code != http.StatusServiceUnavailable {
1122
		t.Fatalf("status = %d, want %d", w.Code, http.StatusServiceUnavailable)
1123
	}
1124
	if got := w.Header().Get("Retry-After"); got != "1" {
1125
		t.Errorf("Retry-After = %q, want 1", got)
1126
	}
1127
}
1128
1129
func TestGitHTTPPush(t *testing.T) {
1130
	srv := testServer(t)
1131
	gitRun(t, "", "--git-dir", srv.repos["testrepo"].GitDir, "config", "http.receivepack", "true")
1132
1133
	httpServer := httptest.NewServer(http.HandlerFunc(srv.route))
1134
	t.Cleanup(httpServer.Close)
1135
1136
	workDir := filepath.Join(t.TempDir(), "work")
1137
	gitRun(t, "", "clone", httpServer.URL+"/testrepo", workDir)
1138
	gitRun(t, workDir, "config", "user.email", "test@example.com")
1139
	gitRun(t, workDir, "config", "user.name", "Test User")
1140
	if err := os.WriteFile(filepath.Join(workDir, "pushed.txt"), []byte("bounded push\n"), 0644); err != nil {
1141
		t.Fatal(err)
1142
	}
1143
	gitRun(t, workDir, "add", "pushed.txt")
1144
	gitRun(t, workDir, "commit", "-m", "Exercise HTTP receive-pack")
1145
	gitRun(t, workDir, "push", "origin", "HEAD")
1146
1147
	if _, err := srv.repos["testrepo"].Git.getBlob("HEAD", "pushed.txt"); err != nil {
1148
		t.Fatalf("pushed blob is unavailable: %v", err)
1149
	}
1150
}
1151
1152
func TestIsGitHTTPRequest(t *testing.T) {
1153
	tests := []struct {
1154
		path  string
1155
		query string
1156
		want  bool
1157
	}{
1158
		{"/repo/info/refs", "service=git-upload-pack", true},
1159
		{"/repo/info/refs", "service=git-receive-pack", true},
1160
		{"/repo/info/refs", "", false},
1161
		{"/repo/git-upload-pack", "", true},
1162
		{"/repo/git-receive-pack", "", true},
1163
		{"/repo/HEAD", "", true},
1164
		{"/repo/objects/pack/pack-abc.pack", "", true},
1165
		{"/repo/refs", "", false},
1166
		{"/repo/", "", false},
1167
		{"/repo/tree/main", "", false},
1168
	}
1169
	for _, tt := range tests {
1170
		u := tt.path
1171
		if tt.query != "" {
1172
			u += "?" + tt.query
1173
		}
1174
		req := httptest.NewRequest(http.MethodGet, u, nil)
1175
		got := isGitHTTPRequest(req)
1176
		if got != tt.want {
1177
			t.Errorf("isGitHTTPRequest(%q, %q) = %v, want %v", tt.path, tt.query, got, tt.want)
1178
		}
1179
	}
1180
}
1181
1182
// ===================================================================
1183
// Session sign / verify
1184
// ===================================================================
1185
1186
func TestSession_SignAndVerify(t *testing.T) {
1187
	signed := signSession("alice")
1188
	handle, ok := verifySession(signed)
1189
	if !ok {
1190
		t.Fatal("expected valid session")
1191
	}
1192
	if handle != "alice" {
1193
		t.Errorf("expected alice, got %q", handle)
1194
	}
1195
}
1196
1197
func TestSession_Garbage(t *testing.T) {
1198
	if _, ok := verifySession("garbage"); ok {
1199
		t.Error("expected invalid")
1200
	}
1201
}
1202
1203
func TestSession_Tampered(t *testing.T) {
1204
	signed := signSession("alice")
1205
	tampered := signed[:len(signed)-2] + "ff"
1206
	if _, ok := verifySession(tampered); ok {
1207
		t.Error("expected tampered session to be invalid")
1208
	}
1209
}
1210
1211
func TestSession_TwoParts(t *testing.T) {
1212
	if _, ok := verifySession("a|b"); ok {
1213
		t.Error("expected invalid with only 2 parts")
1214
	}
1215
}
1216
1217
func TestSession_ExpiredTimestamp(t *testing.T) {
1218
	if _, ok := verifySession("alice|1000000000|fakesig"); ok {
1219
		t.Error("expected expired session to be invalid")
1220
	}
1221
}
1222
1223
func TestSession_BadExpiry(t *testing.T) {
1224
	if _, ok := verifySession("alice|notanumber|fakesig"); ok {
1225
		t.Error("expected invalid with bad expiry")
1226
	}
1227
}
1228
1229
func TestGetSessionHandle_NoCookie(t *testing.T) {
1230
	req := httptest.NewRequest(http.MethodGet, "/", nil)
1231
	if h := getSessionHandle(req); h != "" {
1232
		t.Errorf("expected empty, got %q", h)
1233
	}
1234
}
1235
1236
func TestGetSessionHandle_ValidCookie(t *testing.T) {
1237
	req := httptest.NewRequest(http.MethodGet, "/", nil)
1238
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("bob")})
1239
	if h := getSessionHandle(req); h != "bob" {
1240
		t.Errorf("expected bob, got %q", h)
1241
	}
1242
}
1243
1244
func TestGetSessionHandle_InvalidCookie(t *testing.T) {
1245
	req := httptest.NewRequest(http.MethodGet, "/", nil)
1246
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: "bad"})
1247
	if h := getSessionHandle(req); h != "" {
1248
		t.Errorf("expected empty, got %q", h)
1249
	}
1250
}
1251
1252
func TestSetSessionCookie(t *testing.T) {
1253
	w := httptest.NewRecorder()
1254
	setSessionCookie(w, "carol")
1255
	found := false
1256
	for _, c := range w.Result().Cookies() {
1257
		if c.Name == sessionCookieName {
1258
			found = true
1259
			if !c.HttpOnly {
1260
				t.Error("expected HttpOnly")
1261
			}
1262
			if c.MaxAge <= 0 {
1263
				t.Error("expected positive MaxAge")
1264
			}
1265
			h, ok := verifySession(c.Value)
1266
			if !ok || h != "carol" {
1267
				t.Error("cookie value doesn't verify")
1268
			}
1269
		}
1270
	}
1271
	if !found {
1272
		t.Error("session cookie not set")
1273
	}
1274
}
1275
1276
func TestClearSessionCookie(t *testing.T) {
1277
	w := httptest.NewRecorder()
1278
	clearSessionCookie(w)
1279
	found := false
1280
	for _, c := range w.Result().Cookies() {
1281
		if c.Name == sessionCookieName {
1282
			found = true
1283
			if c.MaxAge >= 0 {
1284
				t.Error("expected negative MaxAge")
1285
			}
1286
		}
1287
	}
1288
	if !found {
1289
		t.Error("session cookie not set for clearing")
1290
	}
1291
}
1292
1293
// ===================================================================
1294
// Database CRUD
1295
// ===================================================================
1296
1297
func TestDB_DiscussionCRUD(t *testing.T) {
1298
	srv := testServer(t)
1299
1300
	id, err := createDiscussion(srv.db, "testrepo", "DB Title", "DB Body", "author1")
1301
	if err != nil {
1302
		t.Fatalf("createDiscussion: %v", err)
1303
	}
1304
	if id == 0 {
1305
		t.Fatal("expected non-zero ID")
1306
	}
1307
1308
	d, err := getDiscussion(srv.db, int(id))
1309
	if err != nil {
1310
		t.Fatalf("getDiscussion: %v", err)
1311
	}
1312
	if d.Title != "DB Title" {
1313
		t.Errorf("title: %q", d.Title)
1314
	}
1315
	if d.Body != "DB Body" {
1316
		t.Errorf("body: %q", d.Body)
1317
	}
1318
	if d.Author != "author1" {
1319
		t.Errorf("author: %q", d.Author)
1320
	}
1321
	if d.Repo != "testrepo" {
1322
		t.Errorf("repo: %q", d.Repo)
1323
	}
1324
}
1325
1326
func TestDB_Replies(t *testing.T) {
1327
	srv := testServer(t)
1328
	id, _ := createDiscussion(srv.db, "testrepo", "T", "", "a")
1329
	createReply(srv.db, int(id), "Reply one", "user1")
1330
	createReply(srv.db, int(id), "Reply two", "user2")
1331
1332
	replies := getReplies(srv.db, int(id))
1333
	if len(replies) != 2 {
1334
		t.Fatalf("expected 2, got %d", len(replies))
1335
	}
1336
	if replies[0].Body != "Reply one" || replies[0].Author != "user1" {
1337
		t.Errorf("reply 0: %+v", replies[0])
1338
	}
1339
	if replies[1].Body != "Reply two" || replies[1].Author != "user2" {
1340
		t.Errorf("reply 1: %+v", replies[1])
1341
	}
1342
}
1343
1344
func TestDB_RepliesEmpty(t *testing.T) {
1345
	srv := testServer(t)
1346
	if replies := getReplies(srv.db, 99999); len(replies) != 0 {
1347
		t.Errorf("expected 0, got %d", len(replies))
1348
	}
1349
}
1350
1351
func TestDB_GetDiscussionNotFound(t *testing.T) {
1352
	srv := testServer(t)
1353
	if _, err := getDiscussion(srv.db, 99999); err == nil {
1354
		t.Error("expected error")
1355
	}
1356
}
1357
1358
func TestDB_ReplyCount(t *testing.T) {
1359
	srv := testServer(t)
1360
	id, _ := createDiscussion(srv.db, "testrepo", "Count", "", "a")
1361
	createReply(srv.db, int(id), "r1", "b")
1362
	createReply(srv.db, int(id), "r2", "c")
1363
	createReply(srv.db, int(id), "r3", "d")
1364
1365
	list := listDiscussions(srv.db, "testrepo")
1366
	if len(list) != 1 {
1367
		t.Fatalf("expected 1, got %d", len(list))
1368
	}
1369
	if list[0].ReplyCount != 3 {
1370
		t.Errorf("expected 3 replies, got %d", list[0].ReplyCount)
1371
	}
1372
}
1373
1374
func TestDB_AvatarUpsert(t *testing.T) {
1375
	srv := testServer(t)
1376
	upsertAvatar(srv.db, "alice", "https://example.com/v1.png")
1377
	if u := getAvatar(srv.db, "alice"); u != "https://example.com/v1.png" {
1378
		t.Errorf("got %q", u)
1379
	}
1380
	upsertAvatar(srv.db, "alice", "https://example.com/v2.png")
1381
	if u := getAvatar(srv.db, "alice"); u != "https://example.com/v2.png" {
1382
		t.Errorf("got %q", u)
1383
	}
1384
}
1385
1386
func TestDB_AvatarNotFound(t *testing.T) {
1387
	srv := testServer(t)
1388
	if u := getAvatar(srv.db, "nobody"); u != "" {
1389
		t.Errorf("expected empty, got %q", u)
1390
	}
1391
}
1392
1393
func TestDB_AvatarShownInDiscussion(t *testing.T) {
1394
	srv := testServer(t)
1395
	upsertAvatar(srv.db, "alice", "https://example.com/alice-av.png")
1396
	id, _ := createDiscussion(srv.db, "testrepo", "Avatar Test", "body", "alice")
1397
1398
	w := get(t, srv, fmt.Sprintf("/testrepo/discussions/%d", id))
1399
	assertCode(t, w, 200)
1400
	assertContains(t, w, "https://example.com/alice-av.png")
1401
}
1402
1403
// ===================================================================
1404
// scanRepositories
1405
// ===================================================================
1406
1407
func TestScanRepositories(t *testing.T) {
1408
	srv := testServer(t)
1409
	repos, err := scanRepositories(srv.scanPath, false)
1410
	if err != nil {
1411
		t.Fatalf("scanRepositories: %v", err)
1412
	}
1413
	if len(repos) != 1 {
1414
		t.Fatalf("expected 1, got %d", len(repos))
1415
	}
1416
	repo := repos["testrepo"]
1417
	if repo == nil {
1418
		t.Fatal("expected 'testrepo'")
1419
	}
1420
	if repo.Description != "A test repository" {
1421
		t.Errorf("description: %q", repo.Description)
1422
	}
1423
	if repo.Owner != "tester" {
1424
		t.Errorf("owner: %q", repo.Owner)
1425
	}
1426
	if repo.LastUpdated.IsZero() {
1427
		t.Error("expected non-zero LastUpdated")
1428
	}
1429
}
1430
1431
func TestScanRepositories_EmptyPublic(t *testing.T) {
1432
	tmpDir := t.TempDir()
1433
	repoDir := filepath.Join(tmpDir, "empty.git")
1434
	gitRun(t, "", "init", "--bare", repoDir)
1435
	if err := os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644); err != nil {
1436
		t.Fatal(err)
1437
	}
1438
1439
	repos, err := scanRepositories(tmpDir, false)
1440
	if err != nil {
1441
		t.Fatalf("scanRepositories: %v", err)
1442
	}
1443
	repo := repos["empty"]
1444
	if repo == nil {
1445
		t.Fatal("expected empty repository")
1446
	}
1447
	if !repo.LastUpdated.IsZero() {
1448
		t.Error("expected zero LastUpdated")
1449
	}
1450
}
1451
1452
func TestScanRepositories_SkipsPrivate(t *testing.T) {
1453
	tmpDir := t.TempDir()
1454
	gitRun(t, "", "init", "--bare", filepath.Join(tmpDir, "private.git"))
1455
	repos, _ := scanRepositories(tmpDir, false)
1456
	if len(repos) != 0 {
1457
		t.Errorf("expected 0, got %d", len(repos))
1458
	}
1459
}
1460
1461
func TestScanRepositories_SkipsHiddenDirs(t *testing.T) {
1462
	tmpDir := t.TempDir()
1463
	repoDir := filepath.Join(tmpDir, ".hidden.git")
1464
	gitRun(t, "", "init", "--bare", repoDir)
1465
	os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
1466
	repos, _ := scanRepositories(tmpDir, false)
1467
	if len(repos) != 0 {
1468
		t.Errorf("expected 0, got %d", len(repos))
1469
	}
1470
}
1471
1472
func TestScanRepositories_SkipsFiles(t *testing.T) {
1473
	tmpDir := t.TempDir()
1474
	os.WriteFile(filepath.Join(tmpDir, "not-a-dir"), []byte("x"), 0644)
1475
	repos, _ := scanRepositories(tmpDir, false)
1476
	if len(repos) != 0 {
1477
		t.Errorf("expected 0, got %d", len(repos))
1478
	}
1479
}
1480
1481
func TestScanRepositories_NonBare(t *testing.T) {
1482
	tmpDir := t.TempDir()
1483
	workDir := filepath.Join(tmpDir, "myrepo")
1484
	gitRun(t, "", "init", workDir)
1485
	os.WriteFile(filepath.Join(workDir, ".git", "public"), nil, 0644)
1486
1487
	repos, _ := scanRepositories(tmpDir, false)
1488
	if len(repos) != 0 {
1489
		t.Errorf("expected 0 without flag, got %d", len(repos))
1490
	}
1491
	repos, _ = scanRepositories(tmpDir, true)
1492
	if len(repos) != 1 {
1493
		t.Fatalf("expected 1 with flag, got %d", len(repos))
1494
	}
1495
}
1496
1497
func TestScanRepositories_StripsGitSuffix(t *testing.T) {
1498
	srv := testServer(t)
1499
	repos, _ := scanRepositories(srv.scanPath, false)
1500
	if _, ok := repos["testrepo"]; !ok {
1501
		t.Error("expected .git suffix stripped")
1502
	}
1503
}
1504
1505
func TestScanRepositories_DefaultDescription(t *testing.T) {
1506
	tmpDir := t.TempDir()
1507
	repoDir := filepath.Join(tmpDir, "repo.git")
1508
	gitRun(t, "", "init", "--bare", repoDir)
1509
	os.WriteFile(filepath.Join(repoDir, "public"), nil, 0644)
1510
	repos, _ := scanRepositories(tmpDir, false)
1511
	if r, ok := repos["repo"]; ok && r.Description != "" {
1512
		t.Errorf("default description should be empty, got %q", r.Description)
1513
	}
1514
}
1515
1516
// ===================================================================
1517
// isBareRepo / isWorkTreeRepo
1518
// ===================================================================
1519
1520
func TestIsBareRepo(t *testing.T) {
1521
	tmpDir := t.TempDir()
1522
	repoDir := filepath.Join(tmpDir, "bare.git")
1523
	gitRun(t, "", "init", "--bare", repoDir)
1524
	if !isBareRepo(repoDir) {
1525
		t.Error("expected bare repo")
1526
	}
1527
	if isBareRepo(tmpDir) {
1528
		t.Error("tmpDir is not bare")
1529
	}
1530
}
1531
1532
func TestIsWorkTreeRepo(t *testing.T) {
1533
	tmpDir := t.TempDir()
1534
	workDir := filepath.Join(tmpDir, "work")
1535
	gitRun(t, "", "init", workDir)
1536
	if !isWorkTreeRepo(workDir) {
1537
		t.Error("expected work tree repo")
1538
	}
1539
	if isWorkTreeRepo(tmpDir) {
1540
		t.Error("tmpDir is not work tree")
1541
	}
1542
}
1543
1544
// ===================================================================
1545
// Template FuncMap
1546
// ===================================================================
1547
1548
func TestFuncMap_ShortHash(t *testing.T) {
1549
	fn := funcMap["shortHash"].(func(string) string)
1550
	if got := fn("abcdef1234567890"); got != "abcdef12" {
1551
		t.Errorf("got %q", got)
1552
	}
1553
	if got := fn("abc"); got != "abc" {
1554
		t.Errorf("got %q", got)
1555
	}
1556
	if got := fn(""); got != "" {
1557
		t.Errorf("got %q", got)
1558
	}
1559
}
1560
1561
func TestFuncMap_StatusLabel(t *testing.T) {
1562
	fn := funcMap["statusLabel"].(func(string) string)
1563
	cases := map[string]string{"A": "added", "D": "deleted", "M": "modified", "R": "renamed", "X": "X"}
1564
	for in, want := range cases {
1565
		if got := fn(in); got != want {
1566
			t.Errorf("statusLabel(%q) = %q, want %q", in, got, want)
1567
		}
1568
	}
1569
}
1570
1571
func TestFuncMap_FormatSize(t *testing.T) {
1572
	fn := funcMap["formatSize"].(func(int64) string)
1573
	if got := fn(500); got != "500 B" {
1574
		t.Errorf("got %q", got)
1575
	}
1576
	if got := fn(2048); !strings.Contains(got, "KiB") {
1577
		t.Errorf("got %q", got)
1578
	}
1579
	if got := fn(2 * 1024 * 1024); !strings.Contains(got, "MiB") {
1580
		t.Errorf("got %q", got)
1581
	}
1582
}
1583
1584
func TestFuncMap_DiffFileName(t *testing.T) {
1585
	fn := funcMap["diffFileName"].(func(DiffFile) string)
1586
	if got := fn(DiffFile{OldName: "old.go", NewName: "new.go"}); got != "new.go" {
1587
		t.Errorf("got %q", got)
1588
	}
1589
	if got := fn(DiffFile{OldName: "old.go", NewName: ""}); got != "old.go" {
1590
		t.Errorf("got %q", got)
1591
	}
1592
}
1593
1594
func TestFuncMap_ParentPath(t *testing.T) {
1595
	fn := funcMap["parentPath"].(func(string) string)
1596
	if got := fn("a/b/c"); got != "a/b" {
1597
		t.Errorf("got %q", got)
1598
	}
1599
	if got := fn("file.go"); got != "" {
1600
		t.Errorf("got %q", got)
1601
	}
1602
}
1603
1604
func TestFuncMap_Indent(t *testing.T) {
1605
	fn := funcMap["indent"].(func(int) string)
1606
	if got := fn(0); got != "" {
1607
		t.Errorf("got %q", got)
1608
	}
1609
	if got := fn(2); !strings.Contains(got, "padding-left") {
1610
		t.Errorf("got %q", got)
1611
	}
1612
}
1613
1614
func TestHighlightLines(t *testing.T) {
1615
	lines := highlightLines("main.go", []string{"package main", "/* comment", "continued */", `var s = "<tag>"`})
1616
	if len(lines) != 4 {
1617
		t.Fatalf("got %d lines, want 4", len(lines))
1618
	}
1619
	for _, line := range lines[1:3] {
1620
		if !strings.Contains(string(line), `class="syntax-comment"`) {
1621
			t.Errorf("multiline comment is not highlighted: %q", line)
1622
		}
1623
	}
1624
	if strings.Contains(string(lines[3]), "<tag>") || !strings.Contains(string(lines[3]), "&lt;tag&gt;") {
1625
		t.Errorf("highlighted source is not escaped: %q", lines[3])
1626
	}
1627
}
1628
1629
func TestFuncMap_Add(t *testing.T) {
1630
	fn := funcMap["add"].(func(int, int) int)
1631
	if got := fn(3, 4); got != 7 {
1632
		t.Errorf("got %d", got)
1633
	}
1634
}
1635
1636
func TestFuncMap_Autolink(t *testing.T) {
1637
	fn := funcMap["autolink"].(func(string) template.HTML)
1638
	tests := []struct {
1639
		input    string
1640
		contains string
1641
	}{
1642
		{"hello https://example.com world", `<a href="https://example.com">https://example.com</a>`},
1643
		{"no links here", "no links here"},
1644
		{"http://foo.com.", `<a href="http://foo.com">http://foo.com</a>`},
1645
		{"<script>", "&lt;script&gt;"},
1646
	}
1647
	for _, tt := range tests {
1648
		got := string(fn(tt.input))
1649
		if !strings.Contains(got, tt.contains) {
1650
			t.Errorf("autolink(%q):\n  got:  %s\n  want substring: %s", tt.input, got, tt.contains)
1651
		}
1652
	}
1653
}
1654
1655
func TestFormatInline(t *testing.T) {
1656
	tests := []struct {
1657
		input string
1658
		want  string
1659
	}{
1660
		// Backtick code
1661
		{"use `fmt.Println` here", "use <code>fmt.Println</code> here"},
1662
		// Code with special chars
1663
		{"run `<script>`", "run <code>&lt;script&gt;</code>"},
1664
		// Italic
1665
		{"this is *important* stuff", "this is <em>important</em> stuff"},
1666
		// Single-char italic
1667
		{"*a* test", "<em>a</em> test"},
1668
		// Angle-bracket link
1669
		{"see <https://example.com> for info", `see <a href="https://example.com">https://example.com</a> for info`},
1670
		// Bare URL (existing behavior)
1671
		{"visit https://example.com today", `visit <a href="https://example.com">https://example.com</a> today`},
1672
		// Bare URL with trailing punctuation
1673
		{"see https://example.com.", `see <a href="https://example.com">https://example.com</a>.`},
1674
		// Mixed formatting
1675
		{"use `code` and *italic* and <https://x.com>",
1676
			`use <code>code</code> and <em>italic</em> and <a href="https://x.com">https://x.com</a>`},
1677
		// No formatting
1678
		{"plain text", "plain text"},
1679
		// HTML escaping in plain text
1680
		{"<b>bold</b>", "&lt;b&gt;bold&lt;/b&gt;"},
1681
		// Asterisk without match (not italic)
1682
		{"a * b * c", "a * b * c"},
1683
		// Italic must not have leading/trailing spaces
1684
		{"not * italic *", "not * italic *"},
1685
	}
1686
	for _, tt := range tests {
1687
		got := formatInline(tt.input)
1688
		if got != tt.want {
1689
			t.Errorf("formatInline(%q):\n  got:  %s\n  want: %s", tt.input, got, tt.want)
1690
		}
1691
	}
1692
}
1693
1694
func TestFuncMap_FormatBody(t *testing.T) {
1695
	fn := funcMap["formatBody"].(func(string) template.HTML)
1696
	tests := []struct {
1697
		input string
1698
		want  string
1699
	}{
1700
		// Single paragraph with code
1701
		{"hello `world`", "<p>hello <code>world</code></p>"},
1702
		// Two paragraphs
1703
		{"first\n\nsecond", "<p>first</p><p>second</p>"},
1704
		// Paragraph with italic and link
1705
		{"*wow* see <https://x.com>", `<p><em>wow</em> see <a href="https://x.com">https://x.com</a></p>`},
1706
		// Empty input
1707
		{"", ""},
1708
		{"  \n\n  ", ""},
1709
		// Fenced code block
1710
		{"before\n\n```\nfmt.Println(\"hi\")\n```\n\nafter",
1711
			"<p>before</p><pre><code>fmt.Println(&#34;hi&#34;)</code></pre><p>after</p>"},
1712
		// Code block with language tag
1713
		{"```go\npackage main\n```",
1714
			"<pre><code>package main</code></pre>"},
1715
		// Code block only
1716
		{"```\nline1\nline2\n```",
1717
			"<pre><code>line1\nline2</code></pre>"},
1718
		// No inline formatting inside code blocks
1719
		{"```\n*not italic* `not code`\n```",
1720
			"<pre><code>*not italic* `not code`</code></pre>"},
1721
		// Unclosed code block
1722
		{"```\nhello",
1723
			"<pre><code>hello</code></pre>"},
1724
		// HTML escaped inside code block
1725
		{"```\n<div>test</div>\n```",
1726
			"<pre><code>&lt;div&gt;test&lt;/div&gt;</code></pre>"},
1727
	}
1728
	for _, tt := range tests {
1729
		got := string(fn(tt.input))
1730
		if got != tt.want {
1731
			t.Errorf("formatBody(%q):\n  got:  %s\n  want: %s", tt.input, got, tt.want)
1732
		}
1733
	}
1734
}
1735
1736
func TestFuncMap_DiffBar_Zero(t *testing.T) {
1737
	fn := funcMap["diffBar"].(func(int, int) template.HTML)
1738
	got := fn(0, 0)
1739
	if string(got) != "" {
1740
		t.Errorf("expected empty, got %q", got)
1741
	}
1742
}
1743
1744
func TestFuncMap_DiffBar_AddOnly(t *testing.T) {
1745
	fn := funcMap["diffBar"].(func(int, int) template.HTML)
1746
	got := string(fn(5, 0))
1747
	if !strings.Contains(got, "bar-add") {
1748
		t.Errorf("expected bar-add, got %q", got)
1749
	}
1750
}
1751
1752
func TestFuncMap_DiffBar_Mixed(t *testing.T) {
1753
	fn := funcMap["diffBar"].(func(int, int) template.HTML)
1754
	got := string(fn(3, 2))
1755
	if !strings.Contains(got, "bar-add") || !strings.Contains(got, "bar-del") {
1756
		t.Errorf("expected both bar-add and bar-del, got %q", got)
1757
	}
1758
}
1759
1760
// ===================================================================
1761
// Template rendering
1762
// ===================================================================
1763
1764
func TestTemplateRender_UnknownTemplate(t *testing.T) {
1765
	srv := testServer(t)
1766
	w := httptest.NewRecorder()
1767
	srv.tmpl.render(w, "nonexistent", nil)
1768
	if !strings.Contains(w.Body.String(), "not found") {
1769
		t.Error("expected 'not found' for unknown template")
1770
	}
1771
}
1772
1773
func TestLoadTemplates(t *testing.T) {
1774
	ts, err := loadTemplates()
1775
	if err != nil {
1776
		t.Fatalf("loadTemplates: %v", err)
1777
	}
1778
	for _, name := range []string{"index", "home", "log", "commit", "refs", "error", "discussions", "discussion", "discussion_new", "login"} {
1779
		if _, ok := ts.templates[name]; !ok {
1780
			t.Errorf("missing template %q", name)
1781
		}
1782
	}
1783
}
1784
1785
// ===================================================================
1786
// Git logic: sortTreeEntries, collapseContext, mimeFromPath,
1787
// parseDiffOutput, parseHunkHeader
1788
// ===================================================================
1789
1790
func TestSortTreeEntries(t *testing.T) {
1791
	entries := []TreeEntryInfo{
1792
		{Name: "b.go", IsDir: false},
1793
		{Name: "a.go", IsDir: false},
1794
		{Name: "z-dir", IsDir: true},
1795
		{Name: "a-dir", IsDir: true},
1796
	}
1797
	sortTreeEntries(entries)
1798
	if entries[0].Name != "a-dir" || entries[1].Name != "z-dir" {
1799
		t.Errorf("dirs not first: %v", entries)
1800
	}
1801
	if entries[2].Name != "a.go" || entries[3].Name != "b.go" {
1802
		t.Errorf("files not sorted: %v", entries)
1803
	}
1804
}
1805
1806
func TestCollapseContext_Empty(t *testing.T) {
1807
	if hunks := collapseContext(nil); hunks != nil {
1808
		t.Errorf("expected nil, got %v", hunks)
1809
	}
1810
}
1811
1812
func TestCollapseContext_AllChanges(t *testing.T) {
1813
	lines := []DiffLine{
1814
		{Type: "add", Content: "a", NewNum: 1},
1815
		{Type: "del", Content: "b", OldNum: 1},
1816
	}
1817
	hunks := collapseContext(lines)
1818
	if len(hunks) != 1 {
1819
		t.Fatalf("expected 1, got %d", len(hunks))
1820
	}
1821
	if len(hunks[0].Lines) != 2 {
1822
		t.Errorf("expected 2 lines, got %d", len(hunks[0].Lines))
1823
	}
1824
}
1825
1826
func TestCollapseContext_SplitsDistantChanges(t *testing.T) {
1827
	var lines []DiffLine
1828
	lines = append(lines, DiffLine{Type: "add", Content: "first", NewNum: 1})
1829
	for i := 0; i < 20; i++ {
1830
		lines = append(lines, DiffLine{Type: "context", Content: "ctx", OldNum: i + 1, NewNum: i + 2})
1831
	}
1832
	lines = append(lines, DiffLine{Type: "add", Content: "second", NewNum: 22})
1833
1834
	hunks := collapseContext(lines)
1835
	if len(hunks) != 2 {
1836
		t.Errorf("expected 2 hunks, got %d", len(hunks))
1837
	}
1838
}
1839
1840
func TestCollapseContext_KeepsNearbyContext(t *testing.T) {
1841
	var lines []DiffLine
1842
	lines = append(lines, DiffLine{Type: "context", Content: "before", OldNum: 1, NewNum: 1})
1843
	lines = append(lines, DiffLine{Type: "add", Content: "new", NewNum: 2})
1844
	lines = append(lines, DiffLine{Type: "context", Content: "after", OldNum: 2, NewNum: 3})
1845
1846
	hunks := collapseContext(lines)
1847
	if len(hunks) != 1 {
1848
		t.Fatalf("expected 1 hunk, got %d", len(hunks))
1849
	}
1850
	if len(hunks[0].Lines) != 3 {
1851
		t.Errorf("expected 3 lines (context around change), got %d", len(hunks[0].Lines))
1852
	}
1853
}
1854
1855
func TestMimeFromPath(t *testing.T) {
1856
	tests := map[string]string{
1857
		"file.go":   "text/plain; charset=utf-8",
1858
		"file.png":  "image/png",
1859
		"file.pdf":  "application/pdf",
1860
		"file.css":  "text/css; charset=utf-8",
1861
		"file.json": "application/json",
1862
		"file.svg":  "image/svg+xml",
1863
		"file.RS":   "text/plain; charset=utf-8",
1864
		"noext":     "",
1865
		"file.xyz":  "",
1866
	}
1867
	for path, want := range tests {
1868
		if got := mimeFromPath(path); got != want {
1869
			t.Errorf("mimeFromPath(%q) = %q, want %q", path, got, want)
1870
		}
1871
	}
1872
}
1873
1874
func TestParseDiffOutput_Added(t *testing.T) {
1875
	output := `diff --git a/file.go b/file.go
1876
new file mode 100644
1877
index 0000000..abc1234
1878
--- /dev/null
1879
+++ b/file.go
1880
@@ -0,0 +1,3 @@
1881
+package main
1882
+
1883
+func main() {}
1884
`
1885
	files := parseDiffOutput(output)
1886
	if len(files) != 1 {
1887
		t.Fatalf("expected 1, got %d", len(files))
1888
	}
1889
	if files[0].Status != "A" {
1890
		t.Errorf("status: %q", files[0].Status)
1891
	}
1892
	if files[0].NewName != "file.go" {
1893
		t.Errorf("name: %q", files[0].NewName)
1894
	}
1895
	if files[0].Stats.Added != 3 {
1896
		t.Errorf("added: %d", files[0].Stats.Added)
1897
	}
1898
}
1899
1900
func TestParseDiffOutput_Deleted(t *testing.T) {
1901
	output := `diff --git a/old.go b/old.go
1902
deleted file mode 100644
1903
index abc1234..0000000
1904
--- a/old.go
1905
+++ /dev/null
1906
@@ -1,2 +0,0 @@
1907
-package old
1908
-
1909
`
1910
	files := parseDiffOutput(output)
1911
	if len(files) != 1 {
1912
		t.Fatalf("expected 1, got %d", len(files))
1913
	}
1914
	if files[0].Status != "D" {
1915
		t.Errorf("status: %q", files[0].Status)
1916
	}
1917
	if files[0].Stats.Deleted != 2 {
1918
		t.Errorf("deleted: %d", files[0].Stats.Deleted)
1919
	}
1920
}
1921
1922
func TestParseDiffOutput_Rename(t *testing.T) {
1923
	output := `diff --git a/old.go b/new.go
1924
rename from old.go
1925
rename to new.go
1926
`
1927
	files := parseDiffOutput(output)
1928
	if len(files) != 1 {
1929
		t.Fatalf("expected 1, got %d", len(files))
1930
	}
1931
	if files[0].Status != "R" {
1932
		t.Errorf("status: %q", files[0].Status)
1933
	}
1934
}
1935
1936
func TestParseDiffOutput_Binary(t *testing.T) {
1937
	output := `diff --git a/image.png b/image.png
1938
new file mode 100644
1939
Binary files /dev/null and b/image.png differ
1940
`
1941
	files := parseDiffOutput(output)
1942
	if len(files) != 1 {
1943
		t.Fatalf("expected 1, got %d", len(files))
1944
	}
1945
	if !files[0].IsBinary {
1946
		t.Error("expected binary")
1947
	}
1948
}
1949
1950
func TestParseDiffOutput_Modified(t *testing.T) {
1951
	output := `diff --git a/file.go b/file.go
1952
index abc..def 100644
1953
--- a/file.go
1954
+++ b/file.go
1955
@@ -1,3 +1,4 @@
1956
 package main
1957
 
1958
 func main() {}
1959
+func extra() {}
1960
`
1961
	files := parseDiffOutput(output)
1962
	if len(files) != 1 {
1963
		t.Fatalf("expected 1, got %d", len(files))
1964
	}
1965
	if files[0].Status != "M" {
1966
		t.Errorf("status: %q", files[0].Status)
1967
	}
1968
	if files[0].Stats.Added != 1 {
1969
		t.Errorf("added: %d", files[0].Stats.Added)
1970
	}
1971
}
1972
1973
func TestParseDiffOutput_MultipleFiles(t *testing.T) {
1974
	output := `diff --git a/a.go b/a.go
1975
new file mode 100644
1976
--- /dev/null
1977
+++ b/a.go
1978
@@ -0,0 +1 @@
1979
+package a
1980
diff --git a/b.go b/b.go
1981
new file mode 100644
1982
--- /dev/null
1983
+++ b/b.go
1984
@@ -0,0 +1 @@
1985
+package b
1986
`
1987
	files := parseDiffOutput(output)
1988
	if len(files) != 2 {
1989
		t.Fatalf("expected 2, got %d", len(files))
1990
	}
1991
}
1992
1993
func TestParseDiffOutput_Empty(t *testing.T) {
1994
	files := parseDiffOutput("")
1995
	if len(files) != 0 {
1996
		t.Errorf("expected 0, got %d", len(files))
1997
	}
1998
}
1999
2000
func TestParseHunkHeader(t *testing.T) {
2001
	tests := []struct {
2002
		input string
2003
		want  []int
2004
	}{
2005
		{"@@ -1,5 +1,7 @@", []int{1, 1}},
2006
		{"@@ -10,3 +20,5 @@ func foo()", []int{10, 20}},
2007
		{"@@ -1 +1 @@", []int{1, 1}},
2008
		{"no hunk", nil},
2009
		{"@@  @@", nil},
2010
	}
2011
	for _, tt := range tests {
2012
		got := parseHunkHeader(tt.input)
2013
		if tt.want == nil {
2014
			if got != nil {
2015
				t.Errorf("parseHunkHeader(%q) = %v, want nil", tt.input, got)
2016
			}
2017
		} else {
2018
			if got == nil || got[0] != tt.want[0] || got[1] != tt.want[1] {
2019
				t.Errorf("parseHunkHeader(%q) = %v, want %v", tt.input, got, tt.want)
2020
			}
2021
		}
2022
	}
2023
}
2024
2025
// ===================================================================
2026
// Git CLI backend (integration with real repo)
2027
// ===================================================================
2028
2029
func TestGit_ResolveRef(t *testing.T) {
2030
	srv := testServer(t)
2031
	git := srv.repos["testrepo"].Git
2032
	hash, err := git.resolveRef(git.getDefaultBranch())
2033
	if err != nil {
2034
		t.Fatalf("resolveRef: %v", err)
2035
	}
2036
	if len(hash) < 40 {
2037
		t.Errorf("expected full hash, got %q", hash)
2038
	}
2039
}
2040
2041
func TestGit_ResolveRefEmpty(t *testing.T) {
2042
	srv := testServer(t)
2043
	git := srv.repos["testrepo"].Git
2044
	hash, err := git.resolveRef("")
2045
	if err != nil {
2046
		t.Fatalf("resolveRef empty: %v", err)
2047
	}
2048
	if len(hash) < 40 {
2049
		t.Errorf("expected full hash, got %q", hash)
2050
	}
2051
}
2052
2053
func TestGit_ResolveRefInvalid(t *testing.T) {
2054
	srv := testServer(t)
2055
	git := srv.repos["testrepo"].Git
2056
	if _, err := git.resolveRef("nonexistent"); err == nil {
2057
		t.Error("expected error")
2058
	}
2059
}
2060
2061
func TestGit_ResolveRefAndPath(t *testing.T) {
2062
	srv := testServer(t)
2063
	git := srv.repos["testrepo"].Git
2064
	ref := git.getDefaultBranch()
2065
2066
	hash, gotRef, path, err := git.resolveRefAndPath(strings.Split(ref+"/main.go", "/"))
2067
	if err != nil {
2068
		t.Fatalf("resolveRefAndPath: %v", err)
2069
	}
2070
	if gotRef != ref {
2071
		t.Errorf("ref: %q, want %q", gotRef, ref)
2072
	}
2073
	if path != "main.go" {
2074
		t.Errorf("path: %q", path)
2075
	}
2076
	if len(hash) < 40 {
2077
		t.Errorf("hash: %q", hash)
2078
	}
2079
}
2080
2081
func TestGit_ResolveRefAndPath_SlashBranch(t *testing.T) {
2082
	srv := testServer(t)
2083
	git := srv.repos["testrepo"].Git
2084
2085
	_, ref, path, err := git.resolveRefAndPath([]string{"feature", "test", "feature.txt"})
2086
	if err != nil {
2087
		t.Fatalf("resolveRefAndPath: %v", err)
2088
	}
2089
	if ref != "feature/test" {
2090
		t.Errorf("ref: %q", ref)
2091
	}
2092
	if path != "feature.txt" {
2093
		t.Errorf("path: %q", path)
2094
	}
2095
}
2096
2097
func TestGit_ResolveRefAndPath_RefOnly(t *testing.T) {
2098
	srv := testServer(t)
2099
	git := srv.repos["testrepo"].Git
2100
	ref := git.getDefaultBranch()
2101
2102
	_, gotRef, path, err := git.resolveRefAndPath([]string{ref})
2103
	if err != nil {
2104
		t.Fatalf("resolveRefAndPath ref only: %v", err)
2105
	}
2106
	if gotRef != ref {
2107
		t.Errorf("ref: %q", gotRef)
2108
	}
2109
	if path != "" {
2110
		t.Errorf("path should be empty: %q", path)
2111
	}
2112
}
2113
2114
func TestGit_ResolveRefAndPath_Invalid(t *testing.T) {
2115
	srv := testServer(t)
2116
	git := srv.repos["testrepo"].Git
2117
	if _, _, _, err := git.resolveRefAndPath([]string{"no", "such", "ref"}); err == nil {
2118
		t.Error("expected error")
2119
	}
2120
}
2121
2122
func TestGit_GetDefaultBranch(t *testing.T) {
2123
	srv := testServer(t)
2124
	git := srv.repos["testrepo"].Git
2125
	branch := git.getDefaultBranch()
2126
	if branch == "" {
2127
		t.Error("expected non-empty")
2128
	}
2129
	if _, err := git.resolveRef(branch); err != nil {
2130
		t.Errorf("branch %q not resolvable: %v", branch, err)
2131
	}
2132
}
2133
2134
func TestGit_GetBranches(t *testing.T) {
2135
	srv := testServer(t)
2136
	git := srv.repos["testrepo"].Git
2137
	branches, err := git.getBranches()
2138
	if err != nil {
2139
		t.Fatalf("getBranches: %v", err)
2140
	}
2141
	if len(branches) < 2 {
2142
		t.Fatalf("expected >= 2, got %d", len(branches))
2143
	}
2144
	names := make(map[string]bool)
2145
	for _, b := range branches {
2146
		names[b.Name] = true
2147
		if b.Hash == "" || b.ShortHash == "" {
2148
			t.Errorf("branch %q has empty hash", b.Name)
2149
		}
2150
		if b.IsTag {
2151
			t.Errorf("branch %q should not be tag", b.Name)
2152
		}
2153
	}
2154
	if !names["feature/test"] {
2155
		t.Error("expected feature/test")
2156
	}
2157
}
2158
2159
func TestGit_GetTags(t *testing.T) {
2160
	srv := testServer(t)
2161
	git := srv.repos["testrepo"].Git
2162
	tags, err := git.getTags()
2163
	if err != nil {
2164
		t.Fatalf("getTags: %v", err)
2165
	}
2166
	if len(tags) != 1 {
2167
		t.Fatalf("expected 1, got %d", len(tags))
2168
	}
2169
	if tags[0].Name != "v1.0" {
2170
		t.Errorf("name: %q", tags[0].Name)
2171
	}
2172
	if !tags[0].IsTag {
2173
		t.Error("expected IsTag")
2174
	}
2175
	if tags[0].Subject != "Release v1.0" {
2176
		t.Errorf("subject: %q", tags[0].Subject)
2177
	}
2178
}
2179
2180
func TestGit_GetTree(t *testing.T) {
2181
	srv := testServer(t)
2182
	git := srv.repos["testrepo"].Git
2183
	hash, _ := git.resolveRef(git.getDefaultBranch())
2184
2185
	entries, err := git.getTree(hash, "")
2186
	if err != nil {
2187
		t.Fatalf("getTree: %v", err)
2188
	}
2189
	if len(entries) == 0 {
2190
		t.Fatal("expected entries")
2191
	}
2192
	// Dirs first.
2193
	seenFile := false
2194
	for _, e := range entries {
2195
		if !e.IsDir {
2196
			seenFile = true
2197
		} else if seenFile {
2198
			t.Error("dir appeared after file โ€” not sorted")
2199
		}
2200
	}
2201
}
2202
2203
func TestGit_GetTree_Subdir(t *testing.T) {
2204
	srv := testServer(t)
2205
	git := srv.repos["testrepo"].Git
2206
	hash, _ := git.resolveRef(git.getDefaultBranch())
2207
2208
	entries, err := git.getTree(hash, "pkg")
2209
	if err != nil {
2210
		t.Fatalf("getTree pkg: %v", err)
2211
	}
2212
	if len(entries) != 1 || entries[0].Name != "lib.go" {
2213
		t.Errorf("expected lib.go, got %v", entries)
2214
	}
2215
}
2216
2217
func TestGit_GetBlob(t *testing.T) {
2218
	srv := testServer(t)
2219
	git := srv.repos["testrepo"].Git
2220
	hash, _ := git.resolveRef(git.getDefaultBranch())
2221
2222
	blob, err := git.getBlob(hash, "main.go")
2223
	if err != nil {
2224
		t.Fatalf("getBlob: %v", err)
2225
	}
2226
	if blob.Name != "main.go" {
2227
		t.Errorf("name: %q", blob.Name)
2228
	}
2229
	if blob.IsBinary {
2230
		t.Error("expected non-binary")
2231
	}
2232
	if !strings.Contains(blob.Content, "package main") {
2233
		t.Error("expected content")
2234
	}
2235
	if blob.Size == 0 {
2236
		t.Error("expected non-zero size")
2237
	}
2238
	if len(blob.Lines) == 0 {
2239
		t.Error("expected lines")
2240
	}
2241
}
2242
2243
func TestGit_GetBlob_Nested(t *testing.T) {
2244
	srv := testServer(t)
2245
	git := srv.repos["testrepo"].Git
2246
	hash, _ := git.resolveRef(git.getDefaultBranch())
2247
2248
	blob, err := git.getBlob(hash, "pkg/lib.go")
2249
	if err != nil {
2250
		t.Fatalf("getBlob: %v", err)
2251
	}
2252
	// Name should be the basename.
2253
	if blob.Name != "lib.go" {
2254
		t.Errorf("name: %q", blob.Name)
2255
	}
2256
}
2257
2258
func TestGit_GetBlob_NotFound(t *testing.T) {
2259
	srv := testServer(t)
2260
	git := srv.repos["testrepo"].Git
2261
	hash, _ := git.resolveRef(git.getDefaultBranch())
2262
	if _, err := git.getBlob(hash, "nonexistent.txt"); err == nil {
2263
		t.Error("expected error")
2264
	}
2265
}
2266
2267
func TestGit_GetRawBlob(t *testing.T) {
2268
	srv := testServer(t)
2269
	git := srv.repos["testrepo"].Git
2270
	hash, _ := git.resolveRef(git.getDefaultBranch())
2271
2272
	reader, ct, size, err := git.getRawBlob(hash, "main.go")
2273
	if err != nil {
2274
		t.Fatalf("getRawBlob: %v", err)
2275
	}
2276
	defer reader.Close()
2277
	if ct != "text/plain; charset=utf-8" {
2278
		t.Errorf("content-type: %q", ct)
2279
	}
2280
	if size == 0 {
2281
		t.Error("expected non-zero size")
2282
	}
2283
	buf := make([]byte, 1024)
2284
	n, _ := reader.Read(buf)
2285
	if !strings.Contains(string(buf[:n]), "package main") {
2286
		t.Error("expected content from reader")
2287
	}
2288
}
2289
2290
func TestGit_IsTreePath(t *testing.T) {
2291
	srv := testServer(t)
2292
	git := srv.repos["testrepo"].Git
2293
	hash, _ := git.resolveRef(git.getDefaultBranch())
2294
2295
	if !git.isTreePath(hash, "") {
2296
		t.Error("empty path should be tree")
2297
	}
2298
	if !git.isTreePath(hash, "pkg") {
2299
		t.Error("pkg should be tree")
2300
	}
2301
	if git.isTreePath(hash, "main.go") {
2302
		t.Error("main.go should not be tree")
2303
	}
2304
	if git.isTreePath(hash, "nonexistent") {
2305
		t.Error("nonexistent should not be tree")
2306
	}
2307
}
2308
2309
func TestGit_BuildTreeNodes(t *testing.T) {
2310
	srv := testServer(t)
2311
	git := srv.repos["testrepo"].Git
2312
	hash, _ := git.resolveRef(git.getDefaultBranch())
2313
2314
	nodes := git.buildTreeNodes(hash, "")
2315
	if len(nodes) == 0 {
2316
		t.Fatal("expected nodes")
2317
	}
2318
	for _, n := range nodes {
2319
		if n.Depth != 0 {
2320
			t.Errorf("expected depth 0 for %q, got %d", n.Name, n.Depth)
2321
		}
2322
		if n.IsOpen {
2323
			t.Errorf("expected not open: %q", n.Name)
2324
		}
2325
	}
2326
}
2327
2328
func TestGit_BuildTreeNodes_ActiveFile(t *testing.T) {
2329
	srv := testServer(t)
2330
	git := srv.repos["testrepo"].Git
2331
	hash, _ := git.resolveRef(git.getDefaultBranch())
2332
2333
	nodes := git.buildTreeNodes(hash, "pkg/lib.go")
2334
	var pkgOpen, libActive bool
2335
	for _, n := range nodes {
2336
		if n.Name == "pkg" && n.IsOpen {
2337
			pkgOpen = true
2338
		}
2339
		if n.Name == "lib.go" && n.IsActive && n.Depth == 1 {
2340
			libActive = true
2341
		}
2342
	}
2343
	if !pkgOpen {
2344
		t.Error("pkg should be open")
2345
	}
2346
	if !libActive {
2347
		t.Error("lib.go should be active at depth 1")
2348
	}
2349
}
2350
2351
func TestGit_BuildTreeNodes_ActiveDir(t *testing.T) {
2352
	srv := testServer(t)
2353
	git := srv.repos["testrepo"].Git
2354
	hash, _ := git.resolveRef(git.getDefaultBranch())
2355
2356
	nodes := git.buildTreeNodes(hash, "pkg")
2357
	var found bool
2358
	for _, n := range nodes {
2359
		if n.Name == "pkg" && n.IsActive && n.IsOpen {
2360
			found = true
2361
		}
2362
	}
2363
	if !found {
2364
		t.Error("pkg should be active and open")
2365
	}
2366
}
2367
2368
func TestGit_GetReadme(t *testing.T) {
2369
	srv := testServer(t)
2370
	git := srv.repos["testrepo"].Git
2371
	hash, _ := git.resolveRef(git.getDefaultBranch())
2372
2373
	readme := git.getReadme(hash, "")
2374
	if readme == nil {
2375
		t.Fatal("expected README")
2376
	}
2377
	if readme.Name != "README.md" {
2378
		t.Errorf("name: %q", readme.Name)
2379
	}
2380
	if !strings.Contains(readme.Content, "Hello world") {
2381
		t.Error("expected content")
2382
	}
2383
}
2384
2385
func TestGit_GetReadme_NotFound(t *testing.T) {
2386
	srv := testServer(t)
2387
	git := srv.repos["testrepo"].Git
2388
	hash, _ := git.resolveRef(git.getDefaultBranch())
2389
	if readme := git.getReadme(hash, "pkg"); readme != nil {
2390
		t.Errorf("expected nil for dir without README, got %+v", readme)
2391
	}
2392
}
2393
2394
func TestGit_GetLog(t *testing.T) {
2395
	srv := testServer(t)
2396
	git := srv.repos["testrepo"].Git
2397
	hash, _ := git.resolveRef(git.getDefaultBranch())
2398
2399
	commits, hasMore, err := git.getLog(hash, 0, 50)
2400
	if err != nil {
2401
		t.Fatalf("getLog: %v", err)
2402
	}
2403
	if len(commits) < 2 {
2404
		t.Fatalf("expected >= 2, got %d", len(commits))
2405
	}
2406
	if hasMore {
2407
		t.Error("should not have more")
2408
	}
2409
	if commits[0].Subject != "Add extra file" {
2410
		t.Errorf("first: %q", commits[0].Subject)
2411
	}
2412
	for _, c := range commits {
2413
		if c.Hash == "" || c.ShortHash == "" || c.Author == "" {
2414
			t.Errorf("empty fields: %+v", c)
2415
		}
2416
	}
2417
}
2418
2419
func TestGit_GetLog_Pagination(t *testing.T) {
2420
	srv := testServer(t)
2421
	git := srv.repos["testrepo"].Git
2422
	hash, _ := git.resolveRef(git.getDefaultBranch())
2423
2424
	commits, hasMore, _ := git.getLog(hash, 0, 1)
2425
	if len(commits) != 1 {
2426
		t.Fatalf("expected 1, got %d", len(commits))
2427
	}
2428
	if !hasMore {
2429
		t.Error("expected hasMore")
2430
	}
2431
}
2432
2433
func TestGit_GetCommit(t *testing.T) {
2434
	srv := testServer(t)
2435
	git := srv.repos["testrepo"].Git
2436
	hash, _ := git.resolveRef(git.getDefaultBranch())
2437
2438
	commit, err := git.getCommit(hash)
2439
	if err != nil {
2440
		t.Fatalf("getCommit: %v", err)
2441
	}
2442
	if commit.Hash != hash {
2443
		t.Errorf("hash mismatch")
2444
	}
2445
	if commit.Author != "Test User" {
2446
		t.Errorf("author: %q", commit.Author)
2447
	}
2448
	if commit.AuthorEmail != "test@example.com" {
2449
		t.Errorf("email: %q", commit.AuthorEmail)
2450
	}
2451
	if commit.AuthorDate.IsZero() {
2452
		t.Error("zero date")
2453
	}
2454
	if len(commit.Parents) == 0 {
2455
		t.Error("expected parents")
2456
	}
2457
}
2458
2459
func TestGit_GetCommit_RootHasNoParents(t *testing.T) {
2460
	srv := testServer(t)
2461
	git := srv.repos["testrepo"].Git
2462
	hash := initialHash(t, srv)
2463
	commit, err := git.getCommit(hash)
2464
	if err != nil {
2465
		t.Fatalf("getCommit: %v", err)
2466
	}
2467
	if len(commit.Parents) != 0 {
2468
		t.Errorf("root commit should have 0 parents, got %d", len(commit.Parents))
2469
	}
2470
}
2471
2472
func TestGit_GetCommit_Invalid(t *testing.T) {
2473
	srv := testServer(t)
2474
	git := srv.repos["testrepo"].Git
2475
	if _, err := git.getCommit("deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"); err == nil {
2476
		t.Error("expected error")
2477
	}
2478
}
2479
2480
func TestGit_GetDiff(t *testing.T) {
2481
	srv := testServer(t)
2482
	git := srv.repos["testrepo"].Git
2483
	hash, _ := git.resolveRef(git.getDefaultBranch())
2484
2485
	files, err := git.getDiff(hash)
2486
	if err != nil {
2487
		t.Fatalf("getDiff: %v", err)
2488
	}
2489
	if len(files) == 0 {
2490
		t.Fatal("expected files")
2491
	}
2492
	found := false
2493
	for _, f := range files {
2494
		if f.NewName == "extra.txt" {
2495
			found = true
2496
			if f.Status != "A" {
2497
				t.Errorf("status: %q", f.Status)
2498
			}
2499
		}
2500
	}
2501
	if !found {
2502
		t.Error("expected extra.txt")
2503
	}
2504
}
2505
2506
func TestGit_GetDiff_RootCommit(t *testing.T) {
2507
	srv := testServer(t)
2508
	git := srv.repos["testrepo"].Git
2509
	hash := initialHash(t, srv)
2510
2511
	files, err := git.getDiff(hash)
2512
	if err != nil {
2513
		t.Fatalf("getDiff root: %v", err)
2514
	}
2515
	if len(files) == 0 {
2516
		t.Fatal("expected files")
2517
	}
2518
	names := make(map[string]bool)
2519
	for _, f := range files {
2520
		names[f.NewName] = true
2521
	}
2522
	for _, want := range []string{"README.md", "main.go"} {
2523
		if !names[want] {
2524
			t.Errorf("expected %q", want)
2525
		}
2526
	}
2527
}
2528
2529
// ===================================================================
2530
// parseRefLines / parseTagRefLines
2531
// ===================================================================
2532
2533
func TestParseRefLines(t *testing.T) {
2534
	input := "main\tabc123\tabc12345\tSubject\tAuthor\t2024-01-15T10:00:00+00:00\n"
2535
	refs := parseRefLines(input, false)
2536
	if len(refs) != 1 {
2537
		t.Fatalf("expected 1, got %d", len(refs))
2538
	}
2539
	if refs[0].Name != "main" || refs[0].IsTag {
2540
		t.Errorf("got %+v", refs[0])
2541
	}
2542
}
2543
2544
func TestParseRefLines_TruncatesShortHash(t *testing.T) {
2545
	input := "main\tabc123456789\tabc123456789\tSub\tAuth\t2024-01-15T10:00:00+00:00\n"
2546
	refs := parseRefLines(input, false)
2547
	if len(refs[0].ShortHash) != 8 {
2548
		t.Errorf("short hash not truncated: %q", refs[0].ShortHash)
2549
	}
2550
}
2551
2552
func TestParseRefLines_Empty(t *testing.T) {
2553
	if refs := parseRefLines("", false); len(refs) != 0 {
2554
		t.Errorf("expected 0, got %d", len(refs))
2555
	}
2556
}
2557
2558
func TestParseRefLines_Malformed(t *testing.T) {
2559
	if refs := parseRefLines("only\ttwo\tfields\n", false); len(refs) != 0 {
2560
		t.Errorf("expected 0, got %d", len(refs))
2561
	}
2562
}
2563
2564
func TestParseTagRefLines(t *testing.T) {
2565
	input := "v1.0\tabc123\tabc12345\t\tRelease\tAuthor\t2024-01-15T10:00:00+00:00\n"
2566
	refs := parseTagRefLines(input)
2567
	if len(refs) != 1 {
2568
		t.Fatalf("expected 1, got %d", len(refs))
2569
	}
2570
	if !refs[0].IsTag || refs[0].Name != "v1.0" {
2571
		t.Errorf("got %+v", refs[0])
2572
	}
2573
}
2574
2575
func TestParseTagRefLines_Annotated(t *testing.T) {
2576
	input := "v2.0\ttagobj1234567890\ttagobj12\tcommitabcdef1234567890\tRelease\tAuth\t2024-01-15T10:00:00+00:00\n"
2577
	refs := parseTagRefLines(input)
2578
	if len(refs) != 1 {
2579
		t.Fatalf("expected 1, got %d", len(refs))
2580
	}
2581
	if refs[0].Hash != "commitabcdef1234567890" {
2582
		t.Errorf("hash should be deref: %q", refs[0].Hash)
2583
	}
2584
	if refs[0].ShortHash != "commitab" {
2585
		t.Errorf("short hash: %q", refs[0].ShortHash)
2586
	}
2587
}
2588
2589
func TestParseTagRefLines_Empty(t *testing.T) {
2590
	if refs := parseTagRefLines(""); len(refs) != 0 {
2591
		t.Errorf("expected 0, got %d", len(refs))
2592
	}
2593
}
2594
2595
// ===================================================================
2596
// newPageData
2597
// ===================================================================
2598
2599
func TestNewPageData_NoRepo(t *testing.T) {
2600
	srv := testServer(t)
2601
	req := httptest.NewRequest(http.MethodGet, "/", nil)
2602
	pd := srv.newPageData(req, nil, "home", "main")
2603
	if pd.SiteTitle != "Test Forge" {
2604
		t.Errorf("title: %q", pd.SiteTitle)
2605
	}
2606
	if pd.Repo != "" {
2607
		t.Errorf("repo: %q", pd.Repo)
2608
	}
2609
	if pd.Section != "home" {
2610
		t.Errorf("section: %q", pd.Section)
2611
	}
2612
	if pd.Ref != "main" {
2613
		t.Errorf("ref: %q", pd.Ref)
2614
	}
2615
	if pd.SiteDescription != "test site description" {
2616
		t.Errorf("site description: %q", pd.SiteDescription)
2617
	}
2618
}
2619
2620
func TestNewPageData_WithRepo(t *testing.T) {
2621
	srv := testServer(t)
2622
	req := httptest.NewRequest(http.MethodGet, "/testrepo/", nil)
2623
	pd := srv.newPageData(req, srv.repos["testrepo"], "home", "main")
2624
	if pd.Repo != "testrepo" {
2625
		t.Errorf("repo: %q", pd.Repo)
2626
	}
2627
	if pd.Description != "A test repository" {
2628
		t.Errorf("description: %q", pd.Description)
2629
	}
2630
}
2631
2632
func TestNewPageData_WithSession(t *testing.T) {
2633
	srv := testServer(t)
2634
	upsertAvatar(srv.db, "alice", "https://example.com/av.png")
2635
	req := httptest.NewRequest(http.MethodGet, "/", nil)
2636
	req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: signSession("alice")})
2637
	pd := srv.newPageData(req, nil, "", "")
2638
	if pd.Handle != "alice" {
2639
		t.Errorf("handle: %q", pd.Handle)
2640
	}
2641
	if pd.Avatar != "https://example.com/av.png" {
2642
		t.Errorf("avatar: %q", pd.Avatar)
2643
	}
2644
}
2645
2646
func TestNewPageData_NoSession(t *testing.T) {
2647
	srv := testServer(t)
2648
	req := httptest.NewRequest(http.MethodGet, "/", nil)
2649
	pd := srv.newPageData(req, nil, "", "")
2650
	if pd.Handle != "" || pd.Avatar != "" {
2651
		t.Errorf("expected empty handle/avatar, got %q/%q", pd.Handle, pd.Avatar)
2652
	}
2653
}