package main import ( "compress/gzip" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "bookmarkmanager/backend/internal/latest" "bookmarkmanager/backend/internal/store" ) func TestLoadLatestPollDefaults(t *testing.T) { t.Setenv("LATEST_CHAPTER_POLL_ENABLED", "") if got := loadLatestPoll(); got != (LatestPoll{Enabled: true}) { t.Fatalf("loadLatestPoll() = %+v, want %+v", got, LatestPoll{Enabled: true}) } } func TestLoadConfigReadsCoverDirectory(t *testing.T) { t.Setenv("COVER_DIR", "/covers") if got := loadConfig().CoverDir; got != "/covers" { t.Fatalf("CoverDir = %q, want /covers", got) } } func TestLoadLatestPollEnabledParsing(t *testing.T) { tests := []struct { raw string want bool }{ {"", true}, {"1", true}, {"true", true}, {"TRUE", true}, {"yes", true}, {"on", true}, {"0", false}, {"false", false}, {"False", false}, {"no", false}, {"off", false}, {"maybe", true}, // unparseable falls back to the default } for _, tt := range tests { t.Run("raw="+tt.raw, func(t *testing.T) { t.Setenv("LATEST_CHAPTER_POLL_ENABLED", tt.raw) if got := loadLatestPoll().Enabled; got != tt.want { t.Fatalf("Enabled = %v, want %v", got, tt.want) } }) } } // newLatestPoller wires the fetcher seams; pace lives in the registry, so // nothing here sizes a cooldown any more. func TestNewLatestPollerWiresFetchers(t *testing.T) { tls := &latest.TLSFetcher{} p := newLatestPoller(nil, LatestPoll{Enabled: true}, tls, nil) if p.Fetch != tls { t.Fatalf("Fetch not wired") } if p.BrowserFetch != nil { t.Fatalf("BrowserFetch = %v, want nil for a browser-less deployment", p.BrowserFetch) } if p.CoverFetch != nil { t.Fatalf("CoverFetch = %v, want nil when the browser is absent", p.CoverFetch) } if p.CoverBytesFetch == nil { t.Fatalf("CoverBytesFetch = nil, want the TLS cover fetcher") } if p.Now == nil { t.Fatalf("Now = nil, want the live clock") } } func TestPutStatusValidation(t *testing.T) { cases := []struct { name string status string want int }{ {"empty is no opinion", "", http.StatusOK}, {"reading", "reading", http.StatusOK}, {"archived", "archived", http.StatusOK}, {"finished is no longer a bucket", "finished", http.StatusBadRequest}, {"garbage", "dropped", http.StatusBadRequest}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := newTestServer(t) body := fmt.Sprintf(`{"title":"Solo","status":%q}`, tc.status) req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", strings.NewReader(body))) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != tc.want { t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String()) } if tc.want != http.StatusOK { return } var got store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } want := tc.status if want == "" { want = "reading" } if got.Status != want { t.Fatalf("stored status = %q, want %q", got.Status, want) } }) } } // A PUT that omits the status field entirely (what a userscript build // predating the column sends) is the actual preserve path — the "" case // above only exercises the fresh-INSERT default and never touches an // existing bucket. This must both keep the archived bucket and still apply // the chapter progress carried in the same request. func TestPutOmittedStatusPreservesArchivedAndAppliesProgress(t *testing.T) { srv := newTestServer(t) seed := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", strings.NewReader(`{"title":"Solo","status":"archived"}`)) rr := httptest.NewRecorder() srv.ServeHTTP(rr, auth(seed)) if rr.Code != http.StatusOK { t.Fatalf("seed status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) } req := httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", strings.NewReader(`{"title":"Solo","last_chapter":"12","last_chapter_num":12}`)) rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(req)) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (body %s)", rr.Code, rr.Body.String()) } var got store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if got.Status != "archived" { t.Fatalf("stored status = %q, want %q", got.Status, "archived") } if got.LastChapterNum != 12 { t.Fatalf("stored last_chapter_num = %v, want 12", got.LastChapterNum) } } func TestGzipCompressesTextNotFonts(t *testing.T) { srv, _ := newWebTestServer(t, testConfig()) cases := []struct { path string want bool }{ {"/static/style.css", true}, {"/static/filter.js", true}, {"/static/htmx.min.js", true}, {"/static/fonts/dm-sans-var-latin.woff2", false}, } for _, tc := range cases { req := httptest.NewRequest(http.MethodGet, tc.path, nil) req.Header.Set("Accept-Encoding", "gzip") rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != http.StatusOK { t.Fatalf("GET %s = %d, want 200", tc.path, rr.Code) } got := rr.Header().Get("Content-Encoding") == "gzip" if got != tc.want { t.Errorf("GET %s Content-Encoding gzip = %v, want %v", tc.path, got, tc.want) } if got { zr, err := gzip.NewReader(rr.Body) if err != nil { t.Fatalf("GET %s: body is not gzip: %v", tc.path, err) } if _, err := io.ReadAll(zr); err != nil { t.Fatalf("GET %s: gzip body did not decode: %v", tc.path, err) } } } // A client that does not ask still gets plain bytes. rr := httptest.NewRecorder() srv.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/static/style.css", nil)) if enc := rr.Header().Get("Content-Encoding"); enc != "" { t.Errorf("Content-Encoding without Accept-Encoding = %q, want empty", enc) } } func TestPutKindValidation(t *testing.T) { cases := []struct { name string kind string want int }{ {"empty is no opinion", "", http.StatusOK}, {"manga", "manga", http.StatusOK}, {"novel", "novel", http.StatusOK}, {"garbage", "comic", http.StatusBadRequest}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv := newTestServer(t) body := fmt.Sprintf(`{"title":"Solo","kind":%q}`, tc.kind) req := auth(httptest.NewRequest(http.MethodPut, "/bookmarks/asura:solo", strings.NewReader(body))) rr := httptest.NewRecorder() srv.ServeHTTP(rr, req) if rr.Code != tc.want { t.Fatalf("status = %d, want %d (body %s)", rr.Code, tc.want, rr.Body.String()) } if tc.want != http.StatusOK { return } var got store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } want := tc.kind if want == "" { want = "manga" } if got.Kind != want { t.Fatalf("stored kind = %q, want %q", got.Kind, want) } }) } } // The preserve path: a novel row re-PUT by a client that omits the field // entirely must stay a novel and still record the progress it carried. func TestPutOmittedKindPreservesNovelAndAppliesProgress(t *testing.T) { srv := newTestServer(t) const key = "/bookmarks/lightnovelworld:a-will-eternal" seed := auth(httptest.NewRequest(http.MethodPut, key, strings.NewReader(`{"title":"A Will Eternal","kind":"novel","last_chapter_num":10}`))) rr := httptest.NewRecorder() srv.ServeHTTP(rr, seed) if rr.Code != http.StatusOK { t.Fatalf("seed status = %d, want 200 (%s)", rr.Code, rr.Body.String()) } rr = httptest.NewRecorder() srv.ServeHTTP(rr, auth(httptest.NewRequest(http.MethodPut, key, strings.NewReader(`{"title":"A Will Eternal","last_chapter_num":11}`)))) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want 200 (%s)", rr.Code, rr.Body.String()) } var got store.Bookmark if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { t.Fatalf("decode: %v", err) } if got.Kind != store.KindNovel { t.Fatalf("Kind = %q, want novel", got.Kind) } if got.LastChapterNum != 11 { t.Fatalf("LastChapterNum = %v, want 11", got.LastChapterNum) } }