docs/TESTING.md (view raw)
1# Testing guide
2
3This document explains the testing strategy and test coverage for the LLM
4Aggregator project.
5
6## Running tests
7
8```bash
9# Run all tests
10go test ./...
11
12# Run tests with verbose output
13go test ./... -v
14
15# Run tests for a specific package
16go test ./internal/aggregator/...
17
18# Run tests with coverage
19go test ./... -cover
20```
21
22## Test packages
23
24### `internal/cli`: CLI parsing
25
26Tests for command-line argument handling using the `go-arg` library.
27
28| Test | Description |
29|------|-------------|
30| `TestFeedsFileParsing` | Verifies `--feeds-file` accepts valid file paths and rejects non-existent files |
31| `TestPromptParsing` | Tests `--prompt` handling including empty strings and special characters |
32| `TestAPIKeyParsing` | Verifies `--api-key` parsing and environment variable fallback |
33| `TestModelParsing` | Tests `--model` with default value (`deepseek-chat`) and custom models |
34| `TestBaseURLParsing` | Tests `--base-url` with various API endpoints |
35| `TestArgsToViperMap` | Ensures CLI arguments convert correctly to Viper configuration |
36| `TestParseKeywords` | Tests keyword parsing from comma-separated strings |
37
38Key features tested are argument validation and default value handling.
39
40---
41
42### `internal/config`: Configuration management
43
44Tests for the Viper-based configuration system.
45
46| Test | Description |
47|------|-------------|
48| `TestConfigParsingAlwaysPasses` | Ensures CLI argument parsing succeeds for valid inputs |
49| `TestConfigLoadWithVariousSources` | Tests loading from config files and environment variables |
50| `TestViperToConfigConversion` | Verifies conversion from Viper to Config struct |
51| `TestBindCLIArgs` | Tests binding CLI arguments to Viper |
52| `TestDefaultConfig` | Verifies default configuration values |
53| `TestConfigPath` | Tests XDG-compliant config path resolution |
54| `TestConfigSaveAndLoad` | Tests config file save/load round-trip |
55| `TestConfigExists` | Tests config file existence checking |
56| `TestEnvironmentVariables` | Tests environment variable loading |
57| `TestIsZero` | Tests `isZero()` helper with all types including pointer types |
58| `TestViperToRuntimePrecedence` | Tests configuration precedence: CLI > env vars > config file > defaults |
59| `TestBindCLIArgsWithPointers` | Tests that nil/zero CLI args don't override existing values |
60
61**Configuration precedence** (highest to lowest):
621. CLI arguments
632. Environment variables (`LLM_AGGREGATOR_*`)
643. Config file (`~/.config/llm_aggregator/config.toml`)
654. Default values
66
67> [!IMPORTANT]
68> CLI arguments only override config file values when explicitly provided. If
69> `--model` is not passed on the command line, the config file or environment
70> variable value is used. This prevents CLI defaults from overriding
71> configuration file values.
72
73---
74
75### `internal/defaults`: Default values
76
77Tests for central default constants.
78
79| Test | Description |
80|------|-------------|
81| `TestDefaultValues` | Verifies all default constants are set correctly |
82| `TestDefaultSystemPrompt` | Tests the default summarisation prompt |
83| `TestDefaultValuesAreSensible` | Validates ranges (e.g., temperature 0-2, max articles > 0) |
84| `TestDefaultBaseURLIsValid` | Ensures URL format is valid |
85| `TestOutputFormatChoices` | Verifies supported output formats |
86
87**Default Values:**
88| Setting | Default | Description |
89|---------|---------|-------------|
90| `DefaultModel` | `deepseek-chat` | LLM model to use |
91| `DefaultBaseURL` | `https://api.deepseek.com` | API endpoint |
92| `DefaultMaxTokens` | `4000` | Maximum tokens per request |
93| `DefaultTemperature` | `0.7` | Sampling temperature (0-2) |
94| `DefaultMaxArticlesPerFeed` | `10` | Articles per feed |
95| `DefaultMaxDaysOld` | `7` | Maximum article age in days |
96| `DefaultMaxTotalArticles` | `20` | Total articles limit |
97| `DefaultOutput` | `text` | Output format |
98| `DefaultIncludeArticles` | `false` | Include articles in output |
99
100---
101
102### `internal/aggregator`: Feed aggregation
103
104Tests for feed parsing, article extraction, and filtering.
105
106| Test | Description |
107|------|-------------|
108| `TestNewFeedAggregator` | Tests aggregator creation with various configurations |
109| `TestNewFeedAggregatorWithProgress` | Tests with progress context |
110| `TestParseFeedsFromFile` | Verifies parsing feeds file with comments |
111| `TestParseFeedsFromFileEmpty` | Tests empty and comment-only files |
112| `TestParseFeedsFromFileNotFound` | Verifies error handling for missing files |
113| `TestParseFeedFromReader` | Tests parsing RSS XML from any `io.Reader` (used by stdin) |
114| `TestParseFeedFromReaderAtom` | Tests parsing Atom XML from `io.Reader` |
115| `TestParseFeedFromReaderMalformed` | Tests error handling for malformed XML |
116| `TestParseFeedFromStdin` | Tests reading a single RSS/Atom feed from stdin via `os.Pipe()` |
117| `TestArticleAgeFiltering` | Tests date-based article filtering |
118| `TestArticleSorting` | Tests date sorting (ascending/descending) |
119| `TestArticleLinkExtraction` | Verifies link extraction |
120| `TestArticleContentExtraction` | Tests content truncation |
121| `TestArticleToMap` | Tests article-to-map conversion |
122| `TestArticleStruct` | Tests Article struct methods |
123| `TestEmptyFeedsFile` | Tests handling of empty feeds file |
124
125**Mock data**
126
127```xml
128<?xml version="1.0" encoding="UTF-8"?>
129<rss version="2.0">
130 <channel>
131 <title>Test Feed</title>
132 <item>
133 <title>Article One</title>
134 <link>https://example.com/article1</link>
135 <pubDate>Wed, 15 Jan 2024 10:00:00 GMT</pubDate>
136 </item>
137 </channel>
138</rss>
139```
140
141---
142
143### `internal/output`: Summary formatting
144
145Tests for JSON, Markdown, and Text output formatting.
146
147| Test | Description |
148|------|-------------|
149| `TestNewFormatter` | Tests formatter creation with valid/invalid formats |
150| `TestFormatDataJSON` | Verifies JSON output structure |
151| `TestFormatDataMarkdown` | Tests Markdown formatting |
152| `TestFormatDataText` | Tests plain text formatting |
153| `TestFormatDataWithArticles` | Verifies articles appear in output |
154| `TestFormatDataMarkdownWithArticles` | Tests article formatting in Markdown |
155| `TestFormatDataDefaultValues` | Tests default values for missing fields |
156| `TestGetStringHelper` | Tests string extraction from map |
157| `TestGetIntHelper` | Tests integer extraction from map |
158| `TestCenterText` | Tests text centering |
159| `TestFormatArticleList` | Tests article list formatting |
160| `TestJSONIndentation` | Verifies proper JSON indentation |
161
162**Output Formats:**
163
164**JSON:**
165```json
166{
167 "title": "LLM Aggregator Summary",
168 "prompt": "Summarise the news",
169 "model": "deepseek-chat",
170 "articles_count": 5,
171 "timestamp": "2024-01-15T10:00:00Z",
172 "summary": "...",
173 "articles": [...]
174}
175```
176
177**Markdown:**
178```markdown
179# LLM Aggregator Summary
180
181## Metadata
182- **Prompt**: Summarise the news
183- **Model**: deepseek-chat
184- **Articles Analysed**: 5
185- **Generated**: 2024-01-15T10:00:00Z
186
187## Summary
188...
189
190## Articles Analysed
191### Article 1: Title
192**Source**: News Feed
193**Link**: [URL](URL)
194```
195
196**Text:**
197```
198================================================================
199 Title
200================================================================
201METADATA
202----------------------------------------
203Prompt: Summarise the news
204Model: deepseek-chat
205...
206================================================================
207 End of Summary
208================================================================
209```
210
211---
212
213### `internal/processor`: Content processing
214
215Tests for article filtering, sorting, and preparation for LLM summarisation.
216
217| Test | Description |
218|------|-------------|
219| `TestNewContentProcessor` | Tests processor creation |
220| `TestProcessArticles` | Tests overall article processing pipeline |
221| `TestFilterArticlesByIncludeKeywords` | Tests keyword inclusion filtering |
222| `TestFilterArticlesByExcludeKeywords` | Tests keyword exclusion filtering |
223| `TestFilterArticlesCaseInsensitive` | Verifies case-insensitive matching |
224| `TestSortArticlesByTitle` | Tests title sorting (A-Z, Z-A) |
225| `TestSortArticlesBySource` | Tests feed name sorting |
226| `TestPrepareForLLM` | Verifies article preparation for LLM |
227| `TestContentTruncation` | Tests content truncation |
228| `TestSetLogger` | Tests logger configuration |
229| `TestUnknownSortField` | Tests default sorting for unknown fields |
230| `TestMixedIncludeExclude` | Tests combined include/exclude filtering |
231| `TestEmptyKeywordLists` | Tests handling of empty keyword lists |
232
233**Filtering Logic:**
234
235```
236Original Articles
237 ↓
238┌─────────────────────────────┐
239│ 1. Check exclude keywords │
240│ - If match → EXCLUDE │
241│ - If no match → NEXT │
242└─────────────────────────────┘
243 ↓
244┌─────────────────────────────┐
245│ 2. Check include keywords │
246│ - If no filter → INCLUDE│
247│ - If match → INCLUDE │
248│ - If no match → EXCLUDE │
249└─────────────────────────────┘
250 ↓
251Filtered Articles
252 ↓
253┌─────────────────────────────┐
254│ 3. Sort (date/title/source) │
255└─────────────────────────────┘
256 ↓
257┌─────────────────────────────┐
258│ 4. Limit to maxTotalArticles│
259└─────────────────────────────┘
260 ↓
261┌─────────────────────────────┐
262│ 5. Truncate content │
263└─────────────────────────────┘
264 ↓
265Prepared Articles
266```
267
268**Sorting Options:**
269| Value | Behaviour |
270|-------|----------|
271| `date` | Sort by publication date (default) |
272| `title` | Sort alphabetically by title |
273| `source` | Sort by feed name |
274
275Keyword matching is case-insensitive.
276
277---
278
279### `internal/runtime`: Execution pipeline
280
281Tests for the full pipeline execution flow.
282
283| Test | Description |
284|------|-------------|
285| `TestExecuteBranchStdinOnly` | Tests `Execute()` with stdin feed only |
286| `TestExecuteBranchFeedsFileOnly` | Tests `Execute()` with feeds file only |
287| `TestExecuteBranchCollated` | Tests `Execute()` with both stdin and feeds file |
288| `TestExecuteBranchNoSource` | Tests `Execute()` returns error when no source specified |
289
290**Branch logic in `Execute()`:**
291```
292if Stdin && FeedsFile != "" → collate stdin then file
293else if Stdin → stdin only
294else if FeedsFile != "" → file only
295else → error: no feed source
296```
297
298---
299
300### `internal/signals`: Signal handling
301
302Tests for graceful shutdown on `SIGINT`, `SIGTERM`, and `SIGHUP`.
303
304| Test | Description |
305|------|-------------|
306| `TestSignalHandler_Watch` | Tests `Watch()` starts and `Stop()` cleans up |
307| `TestSignalHandler_IsExiting` | Tests `IsExiting()` is false after clean `Stop()` |
308| `TestSignalHandler_Stop_Idempotent` | Tests `Stop()` is safe to call multiple times |
309
310**Signal handling flow:**
311```
312SIGINT/SIGTERM/SIGHUP
313 → signal.Notify intercepts (no default exit)
314 → sh.notify channel receives signal
315 → Watch() goroutine: sh.exiting.Store(true)
316 → polling goroutine: cancel() called
317 → HTTP request aborted via context cancellation
318 → rt.Execute returns → partial output → exit 130
319```
320
321---
322
323## Test utilities
324
325### Helper functions
326
327| Package | Function | Purpose |
328|---------|----------|---------|
329| `aggregator` | `createTestArticles()` | Creates sample articles with dates |
330| `aggregator` | `writeTempFile()` | Creates temporary test files |
331| `processor` | `containsString()` | Checks if string exists in slice |
332| `output` | `getString()` | Extracts string from map with default |
333| `output` | `getInt()` | Extracts int from map with default |
334| `output` | `centerText()` | Centres text within width |
335
336### Mock data
337
338| Package | Data | Purpose |
339|---------|------|---------|
340| `aggregator` | `validRSSFeed` | Valid RSS XML for parsing tests |
341| `aggregator` | `atomFeed` | Atom XML for compatibility tests |
342| `aggregator` | `malformedRSSFeed` | Malformed XML for error handling |
343
344---
345
346## Coverage goals
347
348Current coverage status:
349
350| Package | Coverage | Notes |
351|---------|----------|-------|
352| `cli` | 95% | All argument types and help formatting tested |
353| `config` | 82% | Most config operations tested |
354| `aggregator` | 57% | Full parsing pipeline tested; network calls require advanced mocks |
355| `output` | 93% | All formatters tested |
356| `processor` | 76% | All filtering/sorting tested |
357| `runtime` | 0% | Integration-only; context propagation not exercised by unit tests |
358| `signals` | 89% | Handler lifecycle and concurrency tested |
359| `llm` | 0% | Requires advanced API mocking |
360| `tui` | 0% | Requires terminal interaction |
361| `progress` | 0% | Interface only |
362| `tokeniser` | 0% | Depends on library internals |
363| `cmd` | 0% | Entry point |
364
365> [!NOTE]
366> `defaults` and `style` have no testable statements; they contain only
367> constant declarations or subjective parameters.
368
369---
370
371## Writing new tests
372
373### Test naming convention
374
375```go
376func Test<Component>_<Action>(t *testing.T) { ... }
377```
378
379Examples:
380- `TestNewFeedAggregator`: Test constructor
381- `TestFilterArticlesByExcludeKeywords`: Test filter with exclude
382- `TestFormatDataJSON`: Test JSON formatting
383
384### Table-driven tests
385
386Prefer table-driven tests for multiple test cases:
387
388```go
389func TestExample(t *testing.T) {
390 tests := []struct {
391 name string
392 input string
393 expected string
394 }{
395 {"empty string", "", ""},
396 {"single word", "hello", "hello"},
397 {"multiple words", "hello world", "hello world"},
398 }
399
400 for _, tt := range tests {
401 t.Run(tt.name, func(t *testing.T) {
402 result := Function(tt.input)
403 if result != tt.expected {
404 t.Errorf("got %q, want %q", result, tt.expected)
405 }
406 })
407 }
408}
409```
410
411### Using temporary files
412
413```go
414func TestWithTempFile(t *testing.T) {
415 tmpDir := t.TempDir() // Go 1.15+
416 tmpFile := tmpDir + "/test.txt"
417
418 if err := os.WriteFile(tmpFile, []byte("content"), 0644); err != nil {
419 t.Fatalf("Failed to create temp file: %v", err)
420 }
421
422 // Test using tmpFile
423}
424```
425
426### Testing `nil` safety
427
428Always test `nil`/empty cases:
429
430```go
431t.Run("nil input", func(t *testing.T) {
432 result := ProcessArticles(nil, "date", false)
433 if len(result) != 0 {
434 t.Error("Expected empty result for nil input")
435 }
436})
437
438t.Run("empty slice", func(t *testing.T) {
439 result := ProcessArticles([]*Article{}, "date", false)
440 if len(result) != 0 {
441 t.Error("Expected empty result for empty slice")
442 }
443})
444```
445
446---
447
448## Continuous integration
449
450Tests should pass before committing:
451
452```bash
453# Verify all tests pass
454go test ./...
455
456# Verify with race detector
457go test ./... -race
458
459# Verify with coverage
460go test ./... -coverprofile=coverage.out
461go tool cover -html=coverage.out
462```