summaryrefslogtreecommitdiff
path: root/player-server/docs/test-coverage.md
blob: ef97b0fdef156a699d68269a189811ece056be37 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
# Test Coverage Audit

Generated: 2026-05-18  
Tool: `go test -coverprofile=coverage.out ./... && go tool cover -func=coverage.out`  
Total coverage: **73.6%** (target: 60% per project style guide)

---

## 1. Per-Package Coverage Table

| Package | Coverage | Notes |
|---------|----------|-------|
| `cmd/player` | 87.8% | Entry-point wiring; `main` and `recoverBackgroundWorkerPanic` untested (normal for process-level code) |
| `internal` (config) | 94.1% | `loadNumericSettings` partially covered (88.4%) |
| `internal/api` | 78.8% | Several handler paths untested (share thumbnail, remuxed stream, browse-set) |
| `internal/auth` | 88.0% | Good; minor gaps in `generateID` and `CreateSession` error paths |
| `internal/clock` | 100.0% | Full |
| `internal/mediatype` | 42.5% | `MIMETypeForExt` almost entirely untested (11.5%); only a small subset of MIME types exercised |
| `internal/model` | 100.0% | Full |
| `internal/podcast` | 98.4% | Only `ParseFeed` HTTP-error path missing |
| `internal/probe` | 88.7% | `Probe` retry path and `Remux` context-cancellation edge cases are covered; image-EXIF path well tested |
| `internal/repository` | 59.6% | Lowest non-trivial package; `progress_transaction.go` is 0%, several podcast repo methods 0% |
| `internal/scanner` | 73.7% | `NewFSScanner` / `os.FS` adapter 0%; `thumbnailForImage`, `buildThumbnailPath` largely untested |
| `internal/service` | 75.5% | `Bootstrap`, `Login`, `ListTags`, `ToggleEpisodeComplete`, podcast sub-service lifecycle methods all 0% |
| `internal/setassign` | (no stmts) | Package contains no executable statements; only type definitions |
| `internal/thumb` | 94.4% | Near-full; only FFmpeg error path in `Generate` slightly below 100% |

---

## 2. Coverage Gap List

The gaps are grouped by severity (how much untested logic affects correctness risk).

### 2.1 Completely Untested Code Paths

These functions have 0% statement coverage and contain real logic (excluding mock scaffolding files which are 0% by design).

#### `internal/repository/progress_transaction.go` — entire file (0%)

`WithProgressTransaction`, `UpsertProgress`, `GetProgress`, `GetAccumulator`, `UpsertAccumulator`, `IncrementPlayCount` — the transactional progress wrapper is never exercised by any test. This is the code path used by the batch-progress offline sync endpoint, making it a higher-risk gap.

#### `internal/repository/podcast.go` — several methods (0%)

- `UpdateFeed` — feed metadata refresh path
- `ListFeedsBySetID` — listing feeds for a given media set
- `ListFeeds` — global feed listing
- `ListEpisodesByFeed` — episode listing

#### `internal/repository/media.go`

- `UpdateMediaThumbnail` — thumbnail path update after regeneration

#### `internal/repository/share.go`

- `ListSharesByUser` — user-owned shares listing

#### `internal/scanner/fs.go` — entire file (0%)

`ReadDir`, `Stat`, `MkdirAll`, `WalkDir` — the `osFS` adapter wrapping real OS calls. The scanner tests inject a `mockFS` instead, so the production adapter is never exercised.

#### `internal/scanner/scanner.go`

- `NewFSScanner` / `NewFSScannerWithLogger` — real constructors (0%); tests use `mockFS` directly
- `thumbnailForImage` — image thumbnail generation during scan
- `buildThumbnailPath` (21.4%) — path logic for thumbnail placement; most branches not covered

#### `internal/service/auth.go`

- `Bootstrap` (0%) — first-user creation flow
- `Login` (0%) — password-based login
- `CountUsers` (0%), `GetUserByID` (0%) — thin delegation wrappers

#### `internal/service/playback_hints.go`

- `NewPlaybackHintsService` (0%), `GetPlaybackHint` (0%) — the service constructor and its only public method; only the pure helper functions (`buildPlaybackHint`, `containerFromPath`, etc.) are tested in isolation

#### `internal/service/podcast_sub.go`

- `ListFeeds` (0%), `EditFeed` (0%), `UnsubscribeFeed` (0%) — podcast subscription lifecycle beyond the initial subscribe path
- `rollbackSet` (0%) — error-cleanup path

#### `internal/service/podcast_episode.go`

- `ToggleEpisodeComplete` (0%)

#### `internal/service/tag.go`

- `ListTags` (0%)

#### `internal/service/write.go`

- `copyFile` (0%) — file copy used during media import

#### `internal/api/handlers.go`

- `serveDetach` (0%) — JS-detach response handler
- `serveRemuxed` (0%) — remuxed stream handler

#### `internal/api/handlers_media.go`

- `handleGetSetCover` (0%), `handleBrowseSet` (0%), `handleListTags` (0%)

#### `internal/api/handlers_share.go`

- `handleShareThumbnail` (0%), `handleShareDownload` (0%), `handleMyShares` (0%)

---

### 2.2 Undertested Code Paths (Below 60%)

| Location | Coverage | Gap description |
|----------|----------|-----------------|
| `mediatype.MIMETypeForExt` | 11.5% | Large switch with ~50 MIME mappings; only a handful are exercised |
| `api.handleToggleComplete` | 29.4% | Podcast episode toggle — only error branch tested |
| `service.podcast_sub.findPodcastSet` | 33.3% | Set-lookup branching logic |
| `scanner.updateAudioThumbnails` | 42.9% | Audio cover-art update during scan |
| `service.podcast_checker.checkFeed` | 43.6% | Per-feed refresh including HTTP-error and parse-error paths |
| `scanner.gatherCoverImages` | 50.0% | Cover image collection from directories |
| `service.podcast_sub.findExistingFeed` | 50.0% | Duplicate-feed detection |
| `api.handleListPodcasts` | 50.0% | Error path not covered |
| `api.handleSubscribePodcast` | 52.4% | Multiple error branches untested |
| `service.browse.GetThumbnail` | 53.8% | Thumbnail lookup fallback paths |
| `api.handleDownloadEpisode` | 55.6% | Episode download error cases |
| `service.podcast_episode.persistDownloadedEpisode` | 55.6% | Post-download persistence error paths |
| `api.handleListAPITokens` | 57.1% | Token listing edge cases |
| `service.podcast_sub.SubscribeFeed` | 68.8% | Error + rollback paths |

---

### 2.3 Partially Covered but Noteworthy

| Location | Coverage | Gap |
|----------|----------|-----|
| `repository.sqlite.Open` | 68.8% | WAL mode and migration failure paths |
| `service.auth.NewAuthService` | 66.7% | Nil-session-manager branch |
| `service.user.DeleteUser` | 66.7% | Cascade logic |
| `service.progress.MarkNotStarted` | 66.7% | Not-found path |
| `service.gc.notifyRunDone` | 66.7% | Channel-closed path |
| `service.streamer.Open` | 71.4% | Error-handling branches |
| `api.handleError` | 71.4% | The `ErrForbidden` and context-cancelled branches |

---

## 3. Reusable Test Helpers

The following helpers are defined in `*_test.go` files and are relied upon by multiple tests within their package. Later tasks adding tests should reuse them rather than duplicating setup code.

### 3.1 `internal/repository` package

**`newTestStore(t *testing.T) *SQLite`** — `sqlite_test.go:12`  
Opens an in-memory SQLite database and runs all migrations. The canonical way to obtain a disposable repository in any repository-package test. Used by every test in the package.

**`mustCreateAPITokenUser(t, ctx, s, username) int64`** — `api_token_test.go:94`  
Creates a user row and returns the ID, calling `t.Fatal` on error. A convenience shortcut for tests that need a valid user before creating tokens.

**`assertAPIToken(t, token, id, userID, hash, name, lastUsedAt, expiresAt, createdAt)`** — `api_token_test.go:107`  
Field-by-field assertion for an `*model.APIToken`. Reuse whenever a test needs to verify token state after a repository operation.

**`assertTimePtr(t, got, want *time.Time, field string)`** — `api_token_test.go:128`  
Nil-safe pointer comparison for `*time.Time` fields. Used by `assertAPIToken`.

### 3.2 `internal/api` package

**`newTestServer(t, store, hasher, sm, cfg, browseSvc, writeSvc, shareSvc, tagSvc, favSvc, noteSvc, adminSvc, progressSvc, authSvc, fs, streamer...) *Server`** — `handlers_test.go:36`  
Wires up a full `*Server` with all dependencies. Any `nil` argument gets a sensible default (in-memory FS for static files, a stub `MockAuthService` with one admin user). This is the primary entry point for all HTTP-level handler tests.

**`newTestFS(files map[string]string) http.FileSystem`** — `handlers_test.go:28`  
Builds a `testing/fstest.MapFS`-backed `http.FileSystem` from a string map. Used to provide a fake static-file tree without touching the disk.

**`assertCORSHeaders(t, h http.Header, origin string)`** — `cors_test.go:93`  
Checks that all expected CORS response headers are present for a given origin.

### 3.3 `internal/service` package

**`newMockClock() *clock.MockClock`** — `media_test.go:18`  
Returns a `MockClock` fixed at a stable reference time. Used by every service test that involves time-sensitive logic (progress accumulation, GC age, session expiry).

**`setupPodcastService(t) (*podcastService, *repository.MockStore)`** — `podcast_test.go:23`  
Builds a fully wired `podcastService` with a `MockStore`, a temp media root, a discarding `slog.Logger`, and a fixed clock. The canonical starting point for podcast-service tests.

### 3.4 `internal/scanner` package

**`mockDirEntry` / `mockFileInfo`** — `scanner_test.go:22-51`  
Lightweight implementations of `os.DirEntry` and `os.FileInfo` for injecting directory listings into `mockFS`.

**`mockFS`** — `scanner_test.go:59+`  
Implements the `FS` interface (the production `osFS` abstraction). Populated with `entries`, `fileInfos`, and a `walkList`; supports injecting `walkErr`. Used by all scanner unit tests instead of hitting the real filesystem.

### 3.5 `internal/probe` package

**`installFakeFFmpeg(t *testing.T, body string)`** — `remux_test.go:76`  
Writes a shell-script fake `ffmpeg` binary to a temp directory and prepends it to `PATH` for the duration of the test. Allows testing `FFRemuxer` behavior (cancellation, write errors, wait errors) without a real FFmpeg installation.

---

## 4. Test Patterns Observed

### Table-driven tests
The dominant pattern throughout the codebase. Subtests are defined as `[]struct{ name string; ... }` slices and iterated with `for _, tc := range tests { t.Run(tc.name, ...) }`. Examples: `TestProgressService_UpdateProgress` (`service/progress_test.go`), `TestBrowseService_BrowseSet` (`service/browse_test.go`), `TestParseFFprobeOutput` (`probe/probe_test.go`), `TestSQLite_UserRepo` (`repository/sqlite_test.go`).

### Struct-based fakes (not mocks)
`repository.MockStore`, `service.MockMediaService`, and `auth.MockSessionManager` are hand-written structs with optional `*Func` fields of matching function type. When the `*Func` field is nil the method returns a zero value; when set it delegates to the function. This allows per-test customisation without a mocking framework. See `internal/repository/mock.go`, `internal/service/mock.go`, `internal/auth/mock.go`.

These mocks are separately validated by their own tests (`mock_test.go` files in each package) to document zero-value behaviour and verify the `WithFuncs` wiring.

### In-memory SQLite for repository integration tests
All repository tests open an `":memory:"` database via `newTestStore`, run full schema migrations, and exercise real SQL. There is no mocking at the SQL layer; this gives high confidence in queries, scan functions, and constraint behaviour.

### `t.TempDir()` for filesystem tests
Service and scanner tests that need real files (podcast download, thumbnail generation, media upload) use `t.TempDir()` which is automatically cleaned up. They do not patch global paths.

### Fake binary injection via PATH
`installFakeFFmpeg` writes a shell script as `ffmpeg` to a temp dir and prepends it to `PATH`. The same pattern could be applied to `ffprobe` for probe tests that need deterministic binary output.

### No-rows / nil-result tables
`repository/sqlite_no_rows_test.go` and `service/no_rows_test.go` are dedicated files that verify every "get by ID" and similar method returns `nil, nil` (not an error) when the row does not exist. This is a contract test separating the "not found" semantic from the "DB error" semantic.

### HTTP handler tests via `httptest.NewRecorder`
API tests construct a `*Server` via `newTestServer`, call `ServeHTTP` directly with an `httptest.ResponseRecorder`, and assert on status codes and JSON bodies. Dependencies are injected as `MockMediaService`, `MockAdminService`, etc. with `*Func` fields set to return canned responses.