// Tests for the Miniflux client against an httptest fake. Inline fixture // JSON mirrors a real GET /v1/entries response shape (extra API fields // included to prove they're harmlessly ignored). package miniflux import ( "encoding/json" "net/http" "net/http/httptest" "strings" "testing" ) const entriesFixture = `{ "total": 2, "entries": [ { "id": 41001, "user_id": 3, "feed_id": 77, "status": "read", "hash": "abc123", "title": "Why headline-only models are fine actually", "url": "https://example.com/posts/headline-models", "comments_url": "", "published_at": "2024-03-02T10:15:00Z", "created_at": "2024-03-02T11:00:00Z", "changed_at": "2024-03-05T08:30:00Z", "content": "

Article body here.

", "author": "Jane Doe", "share_code": "", "starred": true, "reading_time": 4, "enclosures": null, "feed": { "id": 77, "user_id": 3, "feed_url": "https://example.com/feed.xml", "site_url": "https://example.com", "title": "Example Blog", "checked_at": "2024-03-05T09:00:00Z", "etag_header": "", "last_modified_header": "", "parsing_error_message": "", "parsing_error_count": 0 } }, { "id": 41002, "user_id": 3, "feed_id": 102, "status": "unread", "hash": "def456", "title": "Sixty days of RSS triage: a field report", "url": "https://blog.other.example/triage-report", "comments_url": "", "published_at": "2024-03-04T14:05:00Z", "created_at": "2024-03-04T15:20:00Z", "changed_at": "2024-03-04T15:20:00Z", "content": "

Another body.

", "author": "", "share_code": "", "starred": false, "reading_time": 9, "enclosures": null, "feed": { "id": 102, "user_id": 3, "feed_url": "https://blog.other.example/rss", "site_url": "https://blog.other.example", "title": "Other Blog", "checked_at": "2024-03-05T09:00:00Z", "etag_header": "", "last_modified_header": "", "parsing_error_message": "", "parsing_error_count": 0 } } ] }` // fakeServer records the last request's auth header and query, then replies // with the fixture (or an error status). type fakeServer struct { t *testing.T status int body string gotHeader string gotQuery map[string]string sawRequest bool } func (f *fakeServer) handler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { f.sawRequest = true f.gotHeader = r.Header.Get("X-Auth-Token") f.gotQuery = map[string]string{} for k, v := range r.URL.Query() { f.gotQuery[k] = v[0] } if r.URL.Path != "/v1/entries" { f.t.Errorf("unexpected path: %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") w.WriteHeader(f.status) _, _ = w.Write([]byte(f.body)) } } func newFakeClient(t *testing.T, f *fakeServer) *Client { t.Helper() srv := httptest.NewServer(f.handler()) t.Cleanup(srv.Close) return NewClientWithHTTP(Config{BaseURL: srv.URL, Token: "test-token-123"}, srv.Client()) } func TestEntriesDecodesFixture(t *testing.T) { f := &fakeServer{t: t, status: http.StatusOK, body: entriesFixture} client := newFakeClient(t, f) set, err := client.Entries(Filters{}) if err != nil { t.Fatalf("Entries: %v", err) } if !f.sawRequest { t.Fatal("fake server saw no request") } if f.gotHeader != "test-token-123" { t.Errorf("X-Auth-Token = %q, want test-token-123", f.gotHeader) } if set.Total != 2 { t.Errorf("Total = %d, want 2", set.Total) } if len(set.Entries) != 2 { t.Fatalf("len(Entries) = %d, want 2", len(set.Entries)) } e := set.Entries[0] if e.ID != 41001 { t.Errorf("entry[0].ID = %d, want 41001", e.ID) } if e.Title != "Why headline-only models are fine actually" { t.Errorf("entry[0].Title = %q", e.Title) } if !e.Starred { t.Error("entry[0].Starred = false, want true") } if e.Status != "read" { t.Errorf("entry[0].Status = %q, want read", e.Status) } if e.Feed.ID != 77 || e.Feed.Title != "Example Blog" { t.Errorf("entry[0].Feed = %+v", e.Feed) } if e.PublishedAt.IsZero() || e.ChangedAt.IsZero() { t.Errorf("entry[0] times not parsed: %v / %v", e.PublishedAt, e.ChangedAt) } } func TestEntriesSendsFilters(t *testing.T) { f := &fakeServer{t: t, status: http.StatusOK, body: `{"total":0,"entries":[]}`} client := newFakeClient(t, f) starred := true _, err := client.Entries(Filters{ Starred: &starred, Status: "read", ChangedAfter: 1700000000, PublishedAfter: 1700000000, AfterEntryID: 40999, Limit: 200, Offset: 400, }) if err != nil { t.Fatalf("Entries: %v", err) } want := map[string]string{ "starred": "true", "status": "read", "changed_after": "1700000000", "published_after": "1700000000", "after_entry_id": "40999", "limit": "200", "offset": "400", } for k, v := range want { if f.gotQuery[k] != v { t.Errorf("query %s = %q, want %q", k, f.gotQuery[k], v) } } } func TestEntriesZeroFiltersSendNoParams(t *testing.T) { f := &fakeServer{t: t, status: http.StatusOK, body: `{"total":0,"entries":[]}`} client := newFakeClient(t, f) if _, err := client.Entries(Filters{}); err != nil { t.Fatalf("Entries: %v", err) } if len(f.gotQuery) != 0 { t.Errorf("zero Filters sent params: %v", f.gotQuery) } } func TestEntriesHTTPError(t *testing.T) { f := &fakeServer{t: t, status: http.StatusUnauthorized, body: `{"error_message":"bad token"}`} client := newFakeClient(t, f) _, err := client.Entries(Filters{}) if err == nil { t.Fatal("expected error on 401, got nil") } if !strings.Contains(err.Error(), "401") { t.Errorf("error should name the status: %v", err) } } func TestEntriesBadJSON(t *testing.T) { f := &fakeServer{t: t, status: http.StatusOK, body: `not json`} client := newFakeClient(t, f) if _, err := client.Entries(Filters{}); err == nil { t.Fatal("expected decode error, got nil") } } func TestConfigFromEnv(t *testing.T) { t.Run("primary vars", func(t *testing.T) { t.Setenv("MINIFLUX_URL", "https://flux.example") t.Setenv("MINIFLUX_TOKEN", "tok-primary") cfg, err := ConfigFromEnv() if err != nil { t.Fatalf("ConfigFromEnv: %v", err) } if cfg.BaseURL != "https://flux.example" || cfg.Token != "tok-primary" { t.Errorf("cfg = %+v", cfg) } }) t.Run("fallback vars", func(t *testing.T) { t.Setenv("MFLUX_URL", "https://flux.example") t.Setenv("MFLUX_TOKEN", "tok-fallback") cfg, err := ConfigFromEnv() if err != nil { t.Fatalf("ConfigFromEnv: %v", err) } if cfg.Token != "tok-fallback" { t.Errorf("Token = %q, want tok-fallback", cfg.Token) } }) t.Run("primary beats fallback", func(t *testing.T) { t.Setenv("MINIFLUX_TOKEN", "tok-primary") t.Setenv("MFLUX_TOKEN", "tok-fallback") t.Setenv("MINIFLUX_URL", "https://flux.example") cfg, err := ConfigFromEnv() if err != nil { t.Fatalf("ConfigFromEnv: %v", err) } if cfg.Token != "tok-primary" { t.Errorf("Token = %q, want tok-primary", cfg.Token) } }) t.Run("missing URL errors naming both vars", func(t *testing.T) { t.Setenv("MINIFLUX_TOKEN", "tok") _, err := ConfigFromEnv() if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "MINIFLUX_URL") || !strings.Contains(err.Error(), "MFLUX_URL") { t.Errorf("error should name both URL vars: %v", err) } }) t.Run("missing token errors naming both vars", func(t *testing.T) { t.Setenv("MINIFLUX_URL", "https://flux.example") _, err := ConfigFromEnv() if err == nil { t.Fatal("expected error, got nil") } if !strings.Contains(err.Error(), "MINIFLUX_TOKEN") || !strings.Contains(err.Error(), "MFLUX_TOKEN") { t.Errorf("error should name both token vars: %v", err) } }) } // Compile-time assertion: EntrySet round-trips through encoding/json (a // guard against tag typos drifting from the API's field names). func TestEntrySetJSONRoundTrip(t *testing.T) { var set EntrySet if err := json.Unmarshal([]byte(entriesFixture), &set); err != nil { t.Fatalf("fixture does not decode into EntrySet: %v", err) } b, err := json.Marshal(set) if err != nil { t.Fatalf("marshal: %v", err) } var back EntrySet if err := json.Unmarshal(b, &back); err != nil { t.Fatalf("re-decode: %v", err) } if back.Total != set.Total || len(back.Entries) != len(set.Entries) { t.Error("round trip mismatch") } }