aboutsummaryrefslogtreecommitdiff
path: root/miniflux/client_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'miniflux/client_test.go')
-rw-r--r--miniflux/client_test.go307
1 files changed, 307 insertions, 0 deletions
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": "<p>Article body here.</p>",
+ "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": "<p>Another body.</p>",
+ "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")
+ }
+}