package main import ( "compress/gzip" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "strings" "testing" "time" "bookmarkmanager/backend/internal/store" ) func TestLoadLatestPollDefaults(t *testing.T) { for _, k := range []string{ "LATEST_CHAPTER_POLL_ENABLED", "LATEST_CHAPTER_POLL_COOLDOWN", "LATEST_CHAPTER_POLL_INTERVAL", "LATEST_CHAPTER_POLL_STAGGER", "LATEST_CHAPTER_POLL_BATCH", } { t.Setenv(k, "") } got := loadLatestPoll() want := LatestPoll{ Enabled: true, Cooldown: time.Hour, Interval: 10 * time.Minute, Stagger: 20 * time.Second, Batch: 14, } if got != want { t.Fatalf("loadLatestPoll() = %+v, want %+v", got, want) } } 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) } }) } } func TestLoadLatestPollClampsAndFallsBack(t *testing.T) { tests := []struct { name string env map[string]string wantFrom func(LatestPoll) any want any }{ { name: "cooldown below the floor is clamped up", env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "1m"}, wantFrom: func(p LatestPoll) any { return p.Cooldown }, want: 15 * time.Minute, }, { name: "cooldown at the floor is kept", env: map[string]string{"LATEST_CHAPTER_POLL_COOLDOWN": "15m"}, wantFrom: func(p LatestPoll) any { return p.Cooldown }, want: 15 * time.Minute, }, { name: "a valid override is honoured", env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "5m"}, wantFrom: func(p LatestPoll) any { return p.Interval }, want: 5 * time.Minute, }, { name: "an unparseable duration falls back", env: map[string]string{"LATEST_CHAPTER_POLL_INTERVAL": "ten minutes"}, wantFrom: func(p LatestPoll) any { return p.Interval }, want: 10 * time.Minute, }, { name: "a zero duration falls back", env: map[string]string{"LATEST_CHAPTER_POLL_STAGGER": "0s"}, wantFrom: func(p LatestPoll) any { return p.Stagger }, want: 20 * time.Second, }, { name: "a valid batch is honoured", env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "30"}, wantFrom: func(p LatestPoll) any { return p.Batch }, want: 30, }, { name: "a negative batch falls back", env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "-5"}, wantFrom: func(p LatestPoll) any { return p.Batch }, want: 14, }, { name: "a non-numeric batch falls back", env: map[string]string{"LATEST_CHAPTER_POLL_BATCH": "lots"}, wantFrom: func(p LatestPoll) any { return p.Batch }, want: 14, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { for k, v := range tt.env { t.Setenv(k, v) } if got := tt.wantFrom(loadLatestPoll()); got != tt.want { t.Fatalf("got %v, want %v", got, tt.want) } }) } } 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 web-only", "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) } }