Skip to content

Commit af2b429

Browse files
committed
Add comprehensive unit tests — 448 tests across 23 packages
Coverage: config, database CRUD, auth/TOTP/sessions, OPDS feed generation, rate limiting, download clients (qBit/SABnzbd), file organization (EPUB verification, audio detection, path sanitization), search sources (Anna's Archive, Prowlarr, Gutenberg, Open Library, MangaDex, Nyaa), search filtering/relevance, Torznab XML/caps/handler, source health tracking.
1 parent 9efe328 commit af2b429

26 files changed

Lines changed: 3935 additions & 2 deletions

.github/workflows/test.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
name: Build & Test
2+
on:
3+
push:
4+
branches: [main]
5+
pull_request:
6+
branches: [main]
7+
8+
jobs:
9+
test:
10+
runs-on: ubuntu-latest
11+
steps:
12+
- uses: actions/checkout@v4
13+
- uses: actions/setup-go@v5
14+
with:
15+
go-version: '1.22'
16+
- name: Build
17+
run: go build -o librarr ./cmd/librarr/
18+
- name: Test
19+
run: go test ./... -v -count=1

internal/api/auth_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
package api
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestSessionStore_CreateAndGet(t *testing.T) {
9+
store := NewSessionStore()
10+
11+
token := store.Create(1, "testuser", "admin")
12+
if token == "" {
13+
t.Fatal("expected non-empty token")
14+
}
15+
16+
data, ok := store.Get(token)
17+
if !ok {
18+
t.Fatal("expected session to be valid")
19+
}
20+
if data.UserID != 1 {
21+
t.Errorf("expected user ID 1, got %d", data.UserID)
22+
}
23+
if data.Username != "testuser" {
24+
t.Errorf("expected username testuser, got %s", data.Username)
25+
}
26+
if data.Role != "admin" {
27+
t.Errorf("expected role admin, got %s", data.Role)
28+
}
29+
}
30+
31+
func TestSessionStore_Valid(t *testing.T) {
32+
store := NewSessionStore()
33+
token := store.Create(1, "user", "admin")
34+
35+
if !store.Valid(token) {
36+
t.Error("expected token to be valid")
37+
}
38+
39+
if store.Valid("nonexistent-token") {
40+
t.Error("expected nonexistent token to be invalid")
41+
}
42+
}
43+
44+
func TestSessionStore_Delete(t *testing.T) {
45+
store := NewSessionStore()
46+
token := store.Create(1, "user", "admin")
47+
48+
store.Delete(token)
49+
if store.Valid(token) {
50+
t.Error("expected deleted token to be invalid")
51+
}
52+
}
53+
54+
func TestSessionStore_Expiry(t *testing.T) {
55+
store := NewSessionStore()
56+
token := store.Create(1, "user", "admin")
57+
58+
// Manually expire the session
59+
store.mu.Lock()
60+
store.sessions[token].Expiry = time.Now().Add(-1 * time.Hour)
61+
store.mu.Unlock()
62+
63+
if store.Valid(token) {
64+
t.Error("expected expired token to be invalid")
65+
}
66+
67+
// Should also be cleaned up from the store
68+
store.mu.RLock()
69+
_, exists := store.sessions[token]
70+
store.mu.RUnlock()
71+
if exists {
72+
t.Error("expected expired session to be deleted from store")
73+
}
74+
}
75+
76+
func TestSessionStore_PendingTOTP(t *testing.T) {
77+
store := NewSessionStore()
78+
79+
t.Run("create and validate", func(t *testing.T) {
80+
token := store.CreatePendingTOTP(42)
81+
if token == "" {
82+
t.Fatal("expected non-empty pending token")
83+
}
84+
85+
userID, valid := store.ValidatePendingTOTP(token)
86+
if !valid {
87+
t.Error("expected pending TOTP to be valid")
88+
}
89+
if userID != 42 {
90+
t.Errorf("expected user ID 42, got %d", userID)
91+
}
92+
})
93+
94+
t.Run("consumed after first use", func(t *testing.T) {
95+
token := store.CreatePendingTOTP(1)
96+
store.ValidatePendingTOTP(token)
97+
98+
_, valid := store.ValidatePendingTOTP(token)
99+
if valid {
100+
t.Error("expected pending TOTP to be consumed after use")
101+
}
102+
})
103+
104+
t.Run("expired pending TOTP", func(t *testing.T) {
105+
token := store.CreatePendingTOTP(1)
106+
107+
store.mu.Lock()
108+
store.pendingTOTP[token].Expiry = time.Now().Add(-1 * time.Minute)
109+
store.mu.Unlock()
110+
111+
_, valid := store.ValidatePendingTOTP(token)
112+
if valid {
113+
t.Error("expected expired pending TOTP to be invalid")
114+
}
115+
})
116+
117+
t.Run("nonexistent token", func(t *testing.T) {
118+
_, valid := store.ValidatePendingTOTP("nonexistent")
119+
if valid {
120+
t.Error("expected nonexistent token to be invalid")
121+
}
122+
})
123+
}
124+
125+
func TestSessionStore_UniqueTokens(t *testing.T) {
126+
store := NewSessionStore()
127+
tokens := make(map[string]bool)
128+
129+
for i := 0; i < 100; i++ {
130+
token := store.Create(int64(i), "user", "admin")
131+
if tokens[token] {
132+
t.Fatalf("duplicate token generated: %s", token)
133+
}
134+
tokens[token] = true
135+
}
136+
}
137+
138+
func TestHashPassword_And_CheckPassword(t *testing.T) {
139+
password := "mysecretpassword"
140+
hash, err := hashPassword(password)
141+
if err != nil {
142+
t.Fatalf("hashPassword failed: %v", err)
143+
}
144+
145+
if !checkPassword(password, hash) {
146+
t.Error("expected correct password to match")
147+
}
148+
149+
if checkPassword("wrongpassword", hash) {
150+
t.Error("expected wrong password to not match")
151+
}
152+
153+
if checkPassword("", hash) {
154+
t.Error("expected empty password to not match")
155+
}
156+
}
157+
158+
func TestHashBackupCode(t *testing.T) {
159+
code := "12345678"
160+
hash1 := hashBackupCode(code)
161+
hash2 := hashBackupCode(code)
162+
163+
if hash1 != hash2 {
164+
t.Error("expected same hash for same code")
165+
}
166+
167+
hash3 := hashBackupCode("87654321")
168+
if hash1 == hash3 {
169+
t.Error("expected different hash for different code")
170+
}
171+
172+
if len(hash1) != 64 {
173+
t.Errorf("expected SHA-256 hex length 64, got %d", len(hash1))
174+
}
175+
}
176+
177+
func TestIsExempt(t *testing.T) {
178+
tests := []struct {
179+
path string
180+
expected bool
181+
}{
182+
{"/", true},
183+
{"/health", true},
184+
{"/api/health", true},
185+
{"/api/login", true},
186+
{"/api/login/totp", true},
187+
{"/api/register", true},
188+
{"/api/auth/status", true},
189+
{"/readyz", true},
190+
{"/torznab/api", true},
191+
{"/torznab/api?t=caps", true},
192+
{"/static/style.css", true},
193+
{"/opds", true},
194+
{"/opds/books", true},
195+
{"/metrics", true},
196+
{"/auth/oidc/callback", true},
197+
{"/api/search", false},
198+
{"/api/download", false},
199+
{"/api/library", false},
200+
{"/api/users", false},
201+
}
202+
203+
for _, tt := range tests {
204+
t.Run(tt.path, func(t *testing.T) {
205+
result := isExempt(tt.path)
206+
if result != tt.expected {
207+
t.Errorf("isExempt(%q) = %v, want %v", tt.path, result, tt.expected)
208+
}
209+
})
210+
}
211+
}

internal/api/health.go

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,75 @@ package api
22

33
import (
44
"encoding/json"
5+
"fmt"
56
"net/http"
7+
"runtime"
8+
"time"
69
)
710

11+
// Set at build time via -ldflags
12+
var (
13+
Version = "2.0.0"
14+
BuildTime = "unknown"
15+
GoVersion = runtime.Version()
16+
)
17+
18+
var startTime = time.Now()
19+
820
func (s *Server) handleRoot(w http.ResponseWriter, _ *http.Request) {
921
w.Header().Set("Content-Type", "text/html; charset=utf-8")
1022
w.Write(indexHTML)
1123
}
1224

1325
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
26+
uptime := time.Since(startTime)
27+
28+
// Count enabled sources
29+
enabledSources := 0
30+
sourceNames := []string{}
31+
for _, src := range s.searchMgr.GetSources() {
32+
if src.Enabled() {
33+
enabledSources++
34+
sourceNames = append(sourceNames, src.Name())
35+
}
36+
}
37+
38+
// Library stats
39+
libraryTotal := 0
40+
if stats, err := s.db.GetStats(); err == nil {
41+
if total, ok := stats["total_items"]; ok {
42+
if n, ok := total.(int); ok {
43+
libraryTotal = n
44+
}
45+
}
46+
}
47+
1448
writeJSON(w, http.StatusOK, map[string]interface{}{
15-
"status": "ok",
16-
"version": "2.0.0",
49+
"status": "ok",
50+
"version": Version,
51+
"build_time": BuildTime,
52+
"go_version": GoVersion,
53+
"uptime_seconds": int(uptime.Seconds()),
54+
"uptime_human": formatDuration(uptime),
55+
"sources_enabled": enabledSources,
56+
"sources": sourceNames,
57+
"library_items": libraryTotal,
1758
})
1859
}
1960

61+
func formatDuration(d time.Duration) string {
62+
days := int(d.Hours()) / 24
63+
hours := int(d.Hours()) % 24
64+
mins := int(d.Minutes()) % 60
65+
if days > 0 {
66+
return fmt.Sprintf("%dd %dh %dm", days, hours, mins)
67+
}
68+
if hours > 0 {
69+
return fmt.Sprintf("%dh %dm", hours, mins)
70+
}
71+
return fmt.Sprintf("%dm", mins)
72+
}
73+
2074
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
2175
// Determine if audiobook search is available (prowlarr audiobooks or ABB).
2276
hasAudiobookSearch := false

internal/api/health_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package api
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestFormatDuration(t *testing.T) {
9+
tests := []struct {
10+
name string
11+
d time.Duration
12+
want string
13+
}{
14+
{"zero", 0, "0m"},
15+
{"minutes", 5 * time.Minute, "5m"},
16+
{"hours_minutes", 2*time.Hour + 30*time.Minute, "2h 30m"},
17+
{"days_hours_minutes", 3*24*time.Hour + 5*time.Hour + 15*time.Minute, "3d 5h 15m"},
18+
{"one_day", 24 * time.Hour, "1d 0h 0m"},
19+
{"just_hours", 12 * time.Hour, "12h 0m"},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
got := formatDuration(tt.d)
25+
if got != tt.want {
26+
t.Errorf("formatDuration(%v) = %q, want %q", tt.d, got, tt.want)
27+
}
28+
})
29+
}
30+
}
31+
32+
func TestVersionVars(t *testing.T) {
33+
if Version == "" {
34+
t.Error("Version should not be empty")
35+
}
36+
if GoVersion == "" {
37+
t.Error("GoVersion should not be empty")
38+
}
39+
}

0 commit comments

Comments
 (0)