all repos — llm_aggregator @ 2e7ca2fff0eca5f948de3dec07502b00d7f03039

A CLI tool to aggregate RSS feeds and summarise them with LLMs

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