From 96ab2fcb2ff442698465389d75390afa91629165 Mon Sep 17 00:00:00 2001 From: Sam Scholten Date: Sun, 30 Aug 2026 12:50:24 +1000 Subject: fluxrec: personal Miniflux article recommender --- miniflux/client_test.go | 307 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 307 insertions(+) create mode 100644 miniflux/client_test.go (limited to 'miniflux/client_test.go') diff --git a/miniflux/client_test.go b/miniflux/client_test.go new file mode 100644 index 0000000..8f8c6ad --- /dev/null +++ b/miniflux/client_test.go @@ -0,0 +1,307 @@ +// 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") + } +} -- cgit v1.2.3