all repos — llm_aggregator @ 29a4710e981512a82abba77ec8d7b43e413453e7

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## 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| `DefaultLLMTimeout` | `300` | LLM request timeout (seconds) |
100
101---
102
103### `internal/llm`: LLM client
104
105Tests for the LLM client constructor, defaults, and configuration.
106
107| Test | Description |
108|------|-------------|
109| `TestNewLLMClientTimeout` | Verifies `timeoutSeconds=0` defaults to 300; custom values are stored |
110| `TestNewLLMClientDefaults` | Verifies model, maxTokens, temperature, and llmTimeout all get defaults |
111| `TestNewLLMClientRequiresAPIKey` | Verifies missing API key returns a descriptive error |
112
113**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.
114
115---
116
117### `internal/aggregator`: Feed aggregation
118
119Tests for feed parsing, article extraction, and filtering.
120
121| Test | Description |
122|------|-------------|
123| `TestNewFeedAggregator` | Tests aggregator creation with various configurations |
124| `TestNewFeedAggregatorWithProgress` | Tests with progress context |
125| `TestParseFeedsFromFile` | Verifies parsing feeds file with comments |
126| `TestParseFeedsFromFileEmpty` | Tests empty and comment-only files |
127| `TestParseFeedsFromFileNotFound` | Verifies error handling for missing files |
128| `TestParseFeedFromReader` | Tests parsing RSS XML from any `io.Reader` (used by stdin) |
129| `TestParseFeedFromReaderAtom` | Tests parsing Atom XML from `io.Reader` |
130| `TestParseFeedFromReaderMalformed` | Tests error handling for malformed XML |
131| `TestParseFeedFromStdin` | Tests reading a single RSS/Atom feed from stdin via `os.Pipe()` |
132| `TestArticleAgeFiltering` | Tests date-based article filtering |
133| `TestArticleSorting` | Tests date sorting (ascending/descending) |
134| `TestArticleLinkExtraction` | Verifies link extraction |
135| `TestArticleContentExtraction` | Tests content truncation |
136| `TestArticleToMap` | Tests article-to-map conversion |
137| `TestArticleStruct` | Tests Article struct methods |
138| `TestEmptyFeedsFile` | Tests handling of empty feeds file |
139
140**Mock data**
141
142```xml
143<?xml version="1.0" encoding="UTF-8"?>
144<rss version="2.0">
145  <channel>
146    <title>Test Feed</title>
147    <item>
148      <title>Article One</title>
149      <link>https://example.com/article1</link>
150      <pubDate>Wed, 15 Jan 2024 10:00:00 GMT</pubDate>
151    </item>
152  </channel>
153</rss>
154```
155
156---
157
158### `internal/output`: Summary formatting
159
160Tests for JSON, Markdown, and Text output formatting.
161
162| Test | Description |
163|------|-------------|
164| `TestNewFormatter` | Tests formatter creation with valid/invalid formats |
165| `TestFormatDataJSON` | Verifies JSON output structure |
166| `TestFormatDataMarkdown` | Tests Markdown formatting |
167| `TestFormatDataText` | Tests plain text formatting |
168| `TestFormatDataWithArticles` | Verifies articles appear in output |
169| `TestFormatDataMarkdownWithArticles` | Tests article formatting in Markdown |
170| `TestFormatDataDefaultValues` | Tests default values for missing fields |
171| `TestGetStringHelper` | Tests string extraction from map |
172| `TestGetIntHelper` | Tests integer extraction from map |
173| `TestCenterText` | Tests text centering |
174| `TestFormatArticleList` | Tests article list formatting |
175| `TestJSONIndentation` | Verifies proper JSON indentation |
176
177**Output Formats:**
178
179**JSON:**
180```json
181{
182  "title": "LLM Aggregator Summary",
183  "prompt": "Summarise the news",
184  "model": "deepseek-chat",
185  "articles_count": 5,
186  "timestamp": "2024-01-15T10:00:00Z",
187  "summary": "...",
188  "articles": [...]
189}
190```
191
192**Markdown:**
193```markdown
194# LLM Aggregator Summary
195
196## Metadata
197- **Prompt**: Summarise the news
198- **Model**: deepseek-chat
199- **Articles Analysed**: 5
200- **Generated**: 2024-01-15T10:00:00Z
201
202## Summary
203...
204
205## Articles Analysed
206### Article 1: Title
207**Source**: News Feed
208**Link**: [URL](URL)
209```
210
211**Text:**
212```
213================================================================
214                          Title
215================================================================
216METADATA
217----------------------------------------
218Prompt: Summarise the news
219Model: deepseek-chat
220...
221================================================================
222                       End of Summary
223================================================================
224```
225
226---
227
228### `internal/processor`: Content processing
229
230Tests for article filtering, sorting, and preparation for LLM summarisation.
231
232| Test | Description |
233|------|-------------|
234| `TestNewContentProcessor` | Tests processor creation |
235| `TestProcessArticles` | Tests overall article processing pipeline |
236| `TestFilterArticlesByIncludeKeywords` | Tests keyword inclusion filtering |
237| `TestFilterArticlesByExcludeKeywords` | Tests keyword exclusion filtering |
238| `TestFilterArticlesCaseInsensitive` | Verifies case-insensitive matching |
239| `TestSortArticlesByTitle` | Tests title sorting (A-Z, Z-A) |
240| `TestSortArticlesBySource` | Tests feed name sorting |
241| `TestPrepareForLLM` | Verifies article preparation for LLM |
242| `TestContentTruncation` | Tests content truncation |
243| `TestSetLogger` | Tests logger configuration |
244| `TestUnknownSortField` | Tests default sorting for unknown fields |
245| `TestMixedIncludeExclude` | Tests combined include/exclude filtering |
246| `TestEmptyKeywordLists` | Tests handling of empty keyword lists |
247
248**Filtering Logic:**
249
250```
251Original Articles
252253┌─────────────────────────────┐
254│  1. Check exclude keywords  │
255│     - If match → EXCLUDE    │
256│     - If no match → NEXT    │
257└─────────────────────────────┘
258259┌─────────────────────────────┐
260│  2. Check include keywords  │
261│     - If no filter → INCLUDE│
262│     - If match → INCLUDE    │
263│     - If no match → EXCLUDE │
264└─────────────────────────────┘
265266Filtered Articles
267268┌─────────────────────────────┐
269│  3. Sort (date/title/source) │
270└─────────────────────────────┘
271272┌─────────────────────────────┐
273│  4. Limit to maxTotalArticles│
274└─────────────────────────────┘
275276┌─────────────────────────────┐
277│  5. Truncate content        │
278└─────────────────────────────┘
279280Prepared Articles
281```
282
283**Sorting Options:**
284| Value | Behaviour |
285|-------|----------|
286| `date` | Sort by publication date (default) |
287| `title` | Sort alphabetically by title |
288| `source` | Sort by feed name |
289
290Keyword matching is case-insensitive.
291
292---
293
294### `internal/runtime`: Execution pipeline
295
296Tests for the full pipeline execution flow.
297
298| Test | Description |
299|------|-------------|
300| `TestExecuteBranchStdinOnly` | Tests `Execute()` with stdin feed only |
301| `TestExecuteBranchFeedsFileOnly` | Tests `Execute()` with feeds file only |
302| `TestExecuteBranchCollated` | Tests `Execute()` with both stdin and feeds file |
303| `TestExecuteBranchNoSource` | Tests `Execute()` returns error when no source specified |
304
305**Branch logic in `Execute()`:**
306```
307if Stdin && FeedsFile != "" → collate stdin then file
308else if Stdin           → stdin only
309else if FeedsFile != "" → file only
310else                   → error: no feed source
311```
312
313---
314
315### `internal/signals`: Signal handling
316
317Tests for graceful shutdown on `SIGINT`, `SIGTERM`, and `SIGHUP`.
318
319| Test | Description |
320|------|-------------|
321| `TestSignalHandler_Watch` | Tests `Watch()` starts and `Stop()` cleans up |
322| `TestSignalHandler_IsExiting` | Tests `IsExiting()` is false after clean `Stop()` |
323| `TestSignalHandler_Stop_Idempotent` | Tests `Stop()` is safe to call multiple times |
324
325**Signal handling flow:**
326```
327SIGINT/SIGTERM/SIGHUP
328  → signal.Notify intercepts (no default exit)
329  → sh.notify channel receives signal
330  → Watch() goroutine: sh.exiting.Store(true)
331  → polling goroutine: cancel() called
332  → HTTP request aborted via context cancellation
333  → rt.Execute returns → partial output → exit 130
334```
335
336---
337
338## Test utilities
339
340### Helper functions
341
342| Package | Function | Purpose |
343|---------|----------|---------|
344| `aggregator` | `createTestArticles()` | Creates sample articles with dates |
345| `aggregator` | `writeTempFile()` | Creates temporary test files |
346| `processor` | `containsString()` | Checks if string exists in slice |
347| `output` | `getString()` | Extracts string from map with default |
348| `output` | `getInt()` | Extracts int from map with default |
349| `output` | `centerText()` | Centres text within width |
350
351### Mock data
352
353| Package | Data | Purpose |
354|---------|------|---------|
355| `aggregator` | `validRSSFeed` | Valid RSS XML for parsing tests |
356| `aggregator` | `atomFeed` | Atom XML for compatibility tests |
357| `aggregator` | `malformedRSSFeed` | Malformed XML for error handling |
358
359---
360
361## Coverage goals
362
363Current coverage status:
364
365| Package | Coverage | Notes |
366|---------|----------|-------|
367| `cli` | 95% | All argument types and help formatting tested |
368| `config` | 82% | Most config operations tested |
369| `aggregator` | 57% | Full parsing pipeline tested; network calls require advanced mocks |
370| `output` | 93% | All formatters tested |
371| `processor` | 76% | All filtering/sorting tested |
372| `runtime` | 0% | Integration-only; context propagation not exercised by unit tests |
373| `signals` | 89% | Handler lifecycle and concurrency tested |
374| `llm` | 22% | Constructor defaults, API key validation, and timeout field configuration |
375| `tui` | 0% | Requires terminal interaction |
376| `progress` | 0% | Interface only |
377| `tokeniser` | 0% | Depends on library internals |
378| `cmd` | 0% | Entry point |
379
380> [!NOTE]
381> `defaults` and `style` have no testable statements; they contain only
382> constant declarations or subjective parameters.
383
384---
385
386## Writing new tests
387
388### Test naming convention
389
390```go
391func Test<Component>_<Action>(t *testing.T) { ... }
392```
393
394Examples:
395- `TestNewFeedAggregator`: Test constructor
396- `TestFilterArticlesByExcludeKeywords`: Test filter with exclude
397- `TestFormatDataJSON`: Test JSON formatting
398
399### Table-driven tests
400
401Prefer table-driven tests for multiple test cases:
402
403```go
404func TestExample(t *testing.T) {
405    tests := []struct {
406        name     string
407        input    string
408        expected string
409    }{
410        {"empty string", "", ""},
411        {"single word", "hello", "hello"},
412        {"multiple words", "hello world", "hello world"},
413    }
414
415    for _, tt := range tests {
416        t.Run(tt.name, func(t *testing.T) {
417            result := Function(tt.input)
418            if result != tt.expected {
419                t.Errorf("got %q, want %q", result, tt.expected)
420            }
421        })
422    }
423}
424```
425
426### Using temporary files
427
428```go
429func TestWithTempFile(t *testing.T) {
430    tmpDir := t.TempDir()  // Go 1.15+
431    tmpFile := tmpDir + "/test.txt"
432    
433    if err := os.WriteFile(tmpFile, []byte("content"), 0644); err != nil {
434        t.Fatalf("Failed to create temp file: %v", err)
435    }
436    
437    // Test using tmpFile
438}
439```
440
441### Testing `nil` safety
442
443Always test `nil`/empty cases:
444
445```go
446t.Run("nil input", func(t *testing.T) {
447    result := ProcessArticles(nil, "date", false)
448    if len(result) != 0 {
449        t.Error("Expected empty result for nil input")
450    }
451})
452
453t.Run("empty slice", func(t *testing.T) {
454    result := ProcessArticles([]*Article{}, "date", false)
455    if len(result) != 0 {
456        t.Error("Expected empty result for empty slice")
457    }
458})
459```
460
461---
462
463## Continuous integration
464
465Tests should pass before committing:
466
467```bash
468# Verify all tests pass
469go test ./...
470
471# Verify with race detector
472go test ./... -race
473
474# Verify with coverage
475go test ./... -coverprofile=coverage.out
476go tool cover -html=coverage.out
477```