7c7d597019
Closes #63 Deletes the second way to reach a Cover. Since #62, every Site's cover bytes land in the content-addressed store at creation or on the poll, and the one public route serves them all — nothing needs the kagane proxy anymore. ## What went - **Template-level rewrite:** `Bookmark.CoverURL()` and both templates' use of it. Cards and chrome now render `.Cover` — the wire value — and nothing else. `Bookmark.CoverSource` was dead once `CoverURL` went, so it and its `bookmarkColumns` entry are gone too. - **Kagane-only cover route and its identifier validation:** `GET /img/kagane/{id}`, `web.CoverFetcher`, `coverIDRe`, and the whole `internal/web/cover.go`. - **The proxy's persistence:** `store.KaganeImageID`, `GetKaganeCover`, `PutKaganeCover`, `kaganeCoverSourceURL`, `kaganeCoverRe`. - **The kagane-shaped branch in the byte-fetch routing:** `fetchCoverBytes` no longer takes a `site` argument and no longer names a Site. The URL shape kagane's API publishes is claimed by the browser module itself — `kaganeImageURLRe` + `browserCoverURL` live in `latest/browser.go` with the rest of the per-Site knowledge — and `BrowserFetcher.Image` is now URL-driven (it validates the URL it will navigate to, same SSRF discipline as before). The no-plain-TLS-fallback rule for a claimed URL is preserved: a claimed address with no browser is an error, never a challenge-page fetch. ## What stayed (deliberately) - `BrowserFetcher.Image` and the browser-backed acquisition path: kagane genuinely serves cover bytes behind the challenge + `cross-origin-resource-policy: same-origin`, so the sidecar remains the only fetcher for them — it just routes by URL claim now instead of by Site name. - `fetcherFor`'s per-Site page routing (kagane/novelfull page fetches) — that is the page path, not a cover path. ## Acceptance criteria - [x] Template-level kagane cover rewrite gone - [x] Kagane-only cover route and its identifier validation gone - [x] Tests removed/rewritten against the general route, guarantees kept: unstored + traversal-shaped addresses serve nothing (`TestPublicCoverRejectsUnknownAddress`), non-image content types never echoed (`TestPublicCoverNeverEchoesNonImage` — new; the store-side gate was already pinned by `TestCoverStoreAcceptsAnySourceURL`). Store reopen-persistence and filesystem content-addressing tests rewritten against `PutCover`/`GetCover`, no guarantee lost. - [x] No Site name in a cover code path outside the acquisition module (`grep kagane backend`: store/web/templates/api are clean; remaining hits are `latest/browser.go` + `latest/sites.go`, tests, docs) - [x] Web UI and panel render Covers for all six Sites (templates render the wire address; panel renders `b.cover` — untouched, it never had a kagane path) - [x] `go test ./...` green ## Verification - `go vet ./...` clean - `go test ./...` — all packages pass (root 16.9s, latest 12.7s, store 12.7s, web 0.004s) - `CGO_ENABLED=0 go build` produces the static binary - Cover-path tests run verbosely: `TestPublicCoverServesStoredBytesUnauthenticated`, `TestPublicCoverRejectsUnknownAddress` (unknown/malformed/traversal/empty), `TestPublicCoverNeverEchoesNonImage`, `TestListRendersAcquiredCover`, `TestAcquireKaganeCoverThroughBrowser`, `TestRunOncePrefetchesKaganeCover`, `TestRunOnceRoutesNonKaganeCoverToPublicFetcher` all pass; the three `SMOKE_*` tests skip without the browser sidecar, as designed Live browser verification of the "web UI and panel render Covers for all six Sites" criterion is being run separately with Playwright against real Site pages and a locally mocked backend. Reviewed-on: #73 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
1468 lines
50 KiB
Go
1468 lines
50 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/pgtest"
|
|
)
|
|
|
|
func TestMain(m *testing.M) { os.Exit(pgtest.Main(m)) }
|
|
|
|
// testOwner is the owner every test store seeds. Tests that need a second
|
|
// reader register one (see secondReader).
|
|
var testOwner = Owner{DiscordID: "test-owner", TokenHash: sha256.Sum256([]byte("owner-token-hash"))}
|
|
|
|
// testCoverBaseURL is the public origin every stored cover URL is built from.
|
|
const testCoverBaseURL = "https://bookmarks.test"
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
store, err := Open(pgtest.URL(t), testOwner, t.TempDir(), testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { store.Close() })
|
|
return store
|
|
}
|
|
|
|
// secondReader registers an extra reader through the same path a first login
|
|
// takes, and returns its id.
|
|
func secondReader(t *testing.T, s *Store) int64 {
|
|
t.Helper()
|
|
discordID := "second-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
|
id, err := s.EnsureReader(discordID, sha256.Sum256([]byte("token-"+discordID)))
|
|
if err != nil {
|
|
t.Fatalf("register second reader: %v", err)
|
|
}
|
|
return id
|
|
}
|
|
|
|
// The migration runner runs on every start, so a second Open against a
|
|
// database it already built must be a no-op rather than a duplicate-table
|
|
// error, and must leave the rows alone.
|
|
func TestOpenIsIdempotent(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
coverDir := t.TempDir()
|
|
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
if _, err := first.Upsert(first.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
first.Close()
|
|
|
|
second, err := Open(url, testOwner, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
t.Cleanup(func() { second.Close() })
|
|
|
|
list, err := second.List(second.OwnerID())
|
|
if err != nil {
|
|
t.Fatalf("List: %v", err)
|
|
}
|
|
if len(list) != 1 || list[0].Key != "asura:solo" {
|
|
t.Fatalf("rows after reopen = %+v, want only the seeded one", list)
|
|
}
|
|
}
|
|
|
|
// The hash lookup is the whole authentication path: the store resolves a
|
|
// Reader from the SHA-256 of their presented credential, and nothing else.
|
|
func TestReaderIDForTokenHash(t *testing.T) {
|
|
store := newTestStore(t)
|
|
ownerHash := sha256.Sum256([]byte("owner-token-hash"))
|
|
|
|
id, ok, err := store.ReaderIDForTokenHash(ownerHash)
|
|
if err != nil {
|
|
t.Fatalf("ReaderIDForTokenHash: %v", err)
|
|
}
|
|
if !ok || id != store.OwnerID() {
|
|
t.Fatalf("owner lookup = (%d, %v), want (%d, true)", id, ok, store.OwnerID())
|
|
}
|
|
|
|
if _, ok, err := store.ReaderIDForTokenHash(sha256.Sum256([]byte("nope"))); err != nil {
|
|
t.Fatalf("miss: %v", err)
|
|
} else if ok {
|
|
t.Fatal("unknown hash resolved to a Reader")
|
|
}
|
|
}
|
|
|
|
func TestReaderTokenInfo(t *testing.T) {
|
|
store := newTestStore(t)
|
|
discordID, epoch, err := store.ReaderTokenInfo(store.OwnerID())
|
|
if err != nil {
|
|
t.Fatalf("ReaderTokenInfo: %v", err)
|
|
}
|
|
if discordID != testOwner.DiscordID || epoch != 0 {
|
|
t.Fatalf("ReaderTokenInfo = (%q, %d), want (%q, 0)", discordID, epoch, testOwner.DiscordID)
|
|
}
|
|
}
|
|
|
|
// Rotation swaps the stored hash and bumps the epoch in one step, and the
|
|
// seed must not undo it: a restart re-runs seedOwner, which refreshes the
|
|
// epoch-0 hash only while the row has never been rotated.
|
|
func TestRotateTokenInvalidatesOldAndSurvivesRestart(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
coverDir := t.TempDir()
|
|
store, err := Open(url, testOwner, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
|
|
oldHash := sha256.Sum256([]byte("owner-token-hash"))
|
|
newHash := sha256.Sum256([]byte("rotated-token-hash"))
|
|
if err := store.RotateToken(store.OwnerID(), 0, newHash); err != nil {
|
|
t.Fatalf("RotateToken: %v", err)
|
|
}
|
|
// A second rotation against the stale epoch is refused: the stored hash
|
|
// must never describe a different epoch than the column says.
|
|
if err := store.RotateToken(store.OwnerID(), 0, sha256.Sum256([]byte("third-hash"))); err == nil {
|
|
t.Fatal("stale-epoch rotation succeeded, want error")
|
|
}
|
|
if _, ok, err := store.ReaderIDForTokenHash(oldHash); err != nil {
|
|
t.Fatalf("old lookup: %v", err)
|
|
} else if ok {
|
|
t.Fatal("old hash still resolves after rotation")
|
|
}
|
|
if id, ok, err := store.ReaderIDForTokenHash(newHash); err != nil {
|
|
t.Fatalf("new lookup: %v", err)
|
|
} else if !ok || id != store.OwnerID() {
|
|
t.Fatalf("new hash resolved to (%d, %v), want owner", id, ok)
|
|
}
|
|
if _, epoch, err := store.ReaderTokenInfo(store.OwnerID()); err != nil {
|
|
t.Fatalf("ReaderTokenInfo: %v", err)
|
|
} else if epoch != 1 {
|
|
t.Fatalf("epoch = %d after rotation, want 1", epoch)
|
|
}
|
|
store.Close()
|
|
|
|
reopened, err := Open(url, testOwner, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
t.Cleanup(func() { reopened.Close() })
|
|
if _, ok, err := reopened.ReaderIDForTokenHash(oldHash); err != nil {
|
|
t.Fatalf("old lookup after reopen: %v", err)
|
|
} else if ok {
|
|
t.Fatal("restart resurrected the pre-rotation hash")
|
|
}
|
|
if _, ok, err := reopened.ReaderIDForTokenHash(newHash); err != nil {
|
|
t.Fatalf("new lookup after reopen: %v", err)
|
|
} else if !ok {
|
|
t.Fatal("restart dropped the rotated hash")
|
|
}
|
|
}
|
|
|
|
func TestStoreGet(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
|
|
got, ok, err := store.Get(store.OwnerID(), "asura:solo")
|
|
if err != nil {
|
|
t.Fatalf("Get: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("Get ok = false, want true")
|
|
}
|
|
if got.Title != "Solo Leveling" || got.LastChapterNum != 45 {
|
|
t.Fatalf("Get = %+v, want title/chapter preserved", got)
|
|
}
|
|
}
|
|
|
|
func TestStoreGetMissing(t *testing.T) {
|
|
store := newTestStore(t)
|
|
_, ok, err := store.Get(store.OwnerID(), "asura:nope")
|
|
if err != nil {
|
|
t.Fatalf("Get missing returned error %v, want nil", err)
|
|
}
|
|
if ok {
|
|
t.Fatal("Get ok = true for missing key, want false")
|
|
}
|
|
}
|
|
|
|
func TestBookmarkHasNewChapter(t *testing.T) {
|
|
num := func(f float64) *float64 { return &f }
|
|
cases := []struct {
|
|
name string
|
|
b Bookmark
|
|
want bool
|
|
}{
|
|
{"latest ahead", Bookmark{LastChapterNum: 45, LatestChapterNum: num(47)}, true},
|
|
{"latest equal", Bookmark{LastChapterNum: 45, LatestChapterNum: num(45)}, false},
|
|
{"latest behind", Bookmark{LastChapterNum: 45, LatestChapterNum: num(44)}, false},
|
|
{"latest unknown", Bookmark{LastChapterNum: 45}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := tc.b.HasNewChapter(); got != tc.want {
|
|
t.Fatalf("HasNewChapter() = %v, want %v", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBookmarkContinueURL(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
b Bookmark
|
|
want string
|
|
}{
|
|
{"chapter url present", Bookmark{LastChapterURL: "/ch/45", SeriesURL: "/series"}, "/ch/45"},
|
|
{"falls back to series", Bookmark{SeriesURL: "/series"}, "/series"},
|
|
{"both empty", Bookmark{}, ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := tc.b.ContinueURL(); got != tc.want {
|
|
t.Fatalf("ContinueURL() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// readLatestCheckedAt reads the column directly. It is deliberately absent from
|
|
// Series (see Store.MarkLatestChecked), so tests cannot assert on it any other
|
|
// way. The key splits the same way the API handler derives site/series_id.
|
|
func readLatestCheckedAt(t *testing.T, s *Store, key string) int64 {
|
|
t.Helper()
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
var ts int64
|
|
if err := s.db.QueryRow(
|
|
`SELECT latest_checked_at FROM series WHERE site = $1 AND series_id = $2`,
|
|
site, seriesID).Scan(&ts); err != nil {
|
|
t.Fatalf("read latest_checked_at %q: %v", key, err)
|
|
}
|
|
return ts
|
|
}
|
|
|
|
// seedForCheck inserts a bookmark (and with it its series) and forces the
|
|
// series' latest_checked_at.
|
|
func seedForCheck(t *testing.T, s *Store, key, seriesURL string, checkedAt int64) {
|
|
t.Helper()
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
|
Key: key,
|
|
Site: site,
|
|
SeriesID: seriesID,
|
|
SeriesURL: seriesURL,
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed %q: %v", key, err)
|
|
}
|
|
if err := s.MarkLatestChecked(site, seriesID, checkedAt); err != nil {
|
|
t.Fatalf("seed mark %q: %v", key, err)
|
|
}
|
|
}
|
|
|
|
func TestDueForLatestCheck(t *testing.T) {
|
|
const hour = int64(3600_000)
|
|
now := 10 * hour
|
|
|
|
tests := []struct {
|
|
name string
|
|
checkedAt int64
|
|
seriesURL string
|
|
wantDue bool
|
|
}{
|
|
{"never checked", 0, "https://asurascans.com/comics/a", true},
|
|
{"checked 30m ago", now - hour/2, "https://asurascans.com/comics/a", false},
|
|
{"checked exactly at cutoff", now - hour, "https://asurascans.com/comics/a", true},
|
|
{"checked 2h ago", now - 2*hour, "https://asurascans.com/comics/a", true},
|
|
{"no series url", 0, "", false},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:x", tt.seriesURL, tt.checkedAt)
|
|
|
|
due, err := s.DueForLatestCheck(now-hour, now-hour, nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if got := len(due) == 1; got != tt.wantDue {
|
|
t.Fatalf("due=%v, want %v (got %d rows)", got, tt.wantDue, len(due))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDueForLatestCheckOldestFirstAndLimited(t *testing.T) {
|
|
s := newTestStore(t)
|
|
// Insert newest-checked first so a correct ORDER BY has to reverse it.
|
|
seedForCheck(t, s, "asura:c", "https://asurascans.com/comics/c", 300)
|
|
seedForCheck(t, s, "asura:b", "https://asurascans.com/comics/b", 200)
|
|
seedForCheck(t, s, "asura:a", "https://asurascans.com/comics/a", 100)
|
|
|
|
due, err := s.DueForLatestCheck(1000, 1000, nil, 2)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 2 {
|
|
t.Fatalf("got %d rows, want 2 (limit)", len(due))
|
|
}
|
|
if due[0].Key() != "asura:a" || due[1].Key() != "asura:b" {
|
|
t.Fatalf("got %q,%q; want asura:a,asura:b (oldest first)", due[0].Key(), due[1].Key())
|
|
}
|
|
}
|
|
|
|
func TestMarkLatestChecked(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 0)
|
|
|
|
if err := s.MarkLatestChecked("asura", "x", 4242); err != nil {
|
|
t.Fatalf("MarkLatestChecked: %v", err)
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != 4242 {
|
|
t.Fatalf("latest_checked_at = %d, want 4242", got)
|
|
}
|
|
// A missing series is not an error: its bookmarks may have been deleted
|
|
// mid-fetch.
|
|
if err := s.MarkLatestChecked("asura", "gone", 1); err != nil {
|
|
t.Fatalf("MarkLatestChecked on missing series: %v", err)
|
|
}
|
|
}
|
|
|
|
// Upsert must not touch latest_checked_at. If the column ever migrates into
|
|
// the client-visible write path, this fails and the cooldown is silently dead.
|
|
func TestUpsertPreservesLatestCheckedAt(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:x", "https://asurascans.com/comics/x", 999)
|
|
|
|
b, ok, err := s.Get(s.OwnerID(), "asura:x")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get: %v ok=%v", err, ok)
|
|
}
|
|
b.Title = "changed"
|
|
if _, err := s.Upsert(s.OwnerID(), b); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got := readLatestCheckedAt(t, s, "asura:x"); got != 999 {
|
|
t.Fatalf("latest_checked_at = %d after Upsert, want 999", got)
|
|
}
|
|
}
|
|
|
|
func TestUpsertDefaultsStatusToReading(t *testing.T) {
|
|
store := newTestStore(t)
|
|
stored, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
UpdatedAt: time.Now().UnixMilli(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if stored.Status != StatusReading {
|
|
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
|
}
|
|
}
|
|
|
|
// An empty status means "no opinion" — a userscript build that predates the
|
|
// column must not reset the bucket on every progress PUT.
|
|
func TestUpsertEmptyStatusPreservesStored(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
base.Status = ""
|
|
base.LastChapterNum = 12
|
|
stored, err := store.Upsert(store.OwnerID(), base)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if stored.Status != StatusArchived {
|
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
|
}
|
|
}
|
|
|
|
// Mirrors the poller's read-modify-write in latest.go: Get the current row,
|
|
// mutate only the latest-chapter fields, and Upsert the whole struct back.
|
|
// cur.Status comes from Get (never empty — see scanBookmark), so it must
|
|
// round-trip through Upsert unchanged rather than being reset.
|
|
func TestLatestPollRoundTripPreservesArchived(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
cur, found, err := store.Get(store.OwnerID(), base.Key)
|
|
if err != nil || !found {
|
|
t.Fatalf("Get: found=%v err=%v", found, err)
|
|
}
|
|
|
|
num := 7.0
|
|
cur.LatestChapter = "7"
|
|
cur.LatestChapterNum = &num
|
|
cur.UpdatedAt = time.Now().UnixMilli()
|
|
|
|
stored, err := store.Upsert(store.OwnerID(), cur)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if stored.Status != StatusArchived {
|
|
t.Fatalf("Status = %q, want it preserved as %q", stored.Status, StatusArchived)
|
|
}
|
|
}
|
|
|
|
func TestUpsertReplacesStatusWhenGiven(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Status: StatusArchived, UpdatedAt: time.Now().UnixMilli(),
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
base.Status = StatusReading
|
|
stored, err := store.Upsert(store.OwnerID(), base)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if stored.Status != StatusReading {
|
|
t.Fatalf("Status = %q, want %q", stored.Status, StatusReading)
|
|
}
|
|
}
|
|
|
|
// A status change is not reading progress, so the list must not reorder.
|
|
func TestUpsertStatusChangeKeepsUpdatedAt(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
LastChapter: "45", LastChapterNum: 45,
|
|
UpdatedAt: time.Now().UnixMilli(),
|
|
}
|
|
first, err := store.Upsert(store.OwnerID(), base)
|
|
if err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
base.Status = StatusArchived
|
|
base.UpdatedAt = first.UpdatedAt + 60_000
|
|
stored, err := store.Upsert(store.OwnerID(), base)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if stored.UpdatedAt != first.UpdatedAt {
|
|
t.Fatalf("UpdatedAt = %d, want it frozen at %d", stored.UpdatedAt, first.UpdatedAt)
|
|
}
|
|
}
|
|
|
|
// Archiving is the reason to keep polling — the point is to come back to a
|
|
// series that has moved on. A finished series has nothing left to publish.
|
|
func TestDueForLatestCheckSkipsFinishedKeepsArchived(t *testing.T) {
|
|
store := newTestStore(t)
|
|
for _, tc := range []struct{ key, status string }{
|
|
{"asura:reading", StatusReading},
|
|
{"asura:archived", StatusArchived},
|
|
{"asura:finished", StatusFinished},
|
|
} {
|
|
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: tc.key, Site: "asura", SeriesID: strings.TrimPrefix(tc.key, "asura:"),
|
|
SeriesURL: "https://asurascans.com/comics/" + tc.key,
|
|
Status: tc.status, UpdatedAt: time.Now().UnixMilli(),
|
|
}); err != nil {
|
|
t.Fatalf("seed %s: %v", tc.key, err)
|
|
}
|
|
}
|
|
|
|
due, err := store.DueForLatestCheck(time.Now().UnixMilli(), time.Now().UnixMilli(), nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
got := map[string]bool{}
|
|
for _, sr := range due {
|
|
got[sr.Key()] = true
|
|
}
|
|
if !got["asura:reading"] || !got["asura:archived"] {
|
|
t.Fatalf("due = %v, want reading and archived present", got)
|
|
}
|
|
if got["asura:finished"] {
|
|
t.Fatal("due included a finished series")
|
|
}
|
|
}
|
|
|
|
func TestDisplayChapter(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
raw string
|
|
num float64
|
|
want string
|
|
}{
|
|
{"userscript label", "Chapter 250", 250, "Ch 250"},
|
|
{"abbreviated with dot", "Ch. 12", 12, "Ch 12"},
|
|
{"lowercase abbreviation", "ch 3", 3, "Ch 3"},
|
|
{"bare number from a manual edit", "45.5", 45.5, "Ch 45.5"},
|
|
{"empty falls back to the number", "", 7, "Ch 7"},
|
|
{"empty and zero", "", 0, "Ch 0"},
|
|
{"label with no number passes through", "Prologue", 0, "Prologue"},
|
|
{"title-like label keeps its words", "Chapter of the Sun", 0, "of the Sun"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := displayChapter(tc.raw, tc.num); got != tc.want {
|
|
t.Errorf("displayChapter(%q, %v) = %q, want %q", tc.raw, tc.num, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
|
|
b := Bookmark{LastChapter: "Chapter 9", LastChapterNum: 9}
|
|
if got := b.DisplayChapter(); got != "Ch 9" {
|
|
t.Errorf("DisplayChapter() = %q, want %q", got, "Ch 9")
|
|
}
|
|
if got := b.DisplayLatest(); got != "Ch 0" {
|
|
t.Errorf("DisplayLatest() with nothing captured = %q, want %q", got, "Ch 0")
|
|
}
|
|
num := 11.0
|
|
b.LatestChapter, b.LatestChapterNum = "Chapter 11", &num
|
|
if got := b.DisplayLatest(); got != "Ch 11" {
|
|
t.Errorf("DisplayLatest() = %q, want %q", got, "Ch 11")
|
|
}
|
|
}
|
|
|
|
func TestUpsertKindDefaultsToManga(t *testing.T) {
|
|
store := newTestStore(t)
|
|
got, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindManga {
|
|
t.Fatalf("Kind = %q, want %q", got.Kind, KindManga)
|
|
}
|
|
}
|
|
|
|
func TestUpsertKindRoundTrips(t *testing.T) {
|
|
store := newTestStore(t)
|
|
got, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
|
SeriesID: "a-will-eternal", Kind: KindNovel, UpdatedAt: 1000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindNovel {
|
|
t.Fatalf("Kind = %q, want %q", got.Kind, KindNovel)
|
|
}
|
|
}
|
|
|
|
// The real hazard: a client that predates the column sends no kind at all. That
|
|
// must keep the stored library, not silently demote a novel to manga.
|
|
func TestUpsertEmptyKindKeepsStoredValue(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
|
SeriesID: "a-will-eternal", Kind: KindNovel, LastChapterNum: 10, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
got, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "lightnovelworld:a-will-eternal", Site: "lightnovelworld",
|
|
SeriesID: "a-will-eternal", Kind: "", LastChapterNum: 11, UpdatedAt: 2000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindNovel {
|
|
t.Fatalf("Kind = %q, want %q — an empty kind must not reset the library", got.Kind, KindNovel)
|
|
}
|
|
if got.LastChapterNum != 11 {
|
|
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
|
|
}
|
|
}
|
|
|
|
// The 0002 backfill must survive a database that already ran 0001 with real
|
|
// rows: one series row per distinct (site, series_id) carrying the moved
|
|
// columns, and the bookmark keeping the rest. That is the upgrade path for
|
|
// every deployed database, so it is exercised rather than trusted.
|
|
func TestMigration0002BackfillsExistingBookmarks(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
// Run only 0001, as a database created before this change would have.
|
|
// migrate() normally creates the version table first; do the same here.
|
|
if _, err := db.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version bigint PRIMARY KEY,
|
|
applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
|
|
t.Fatalf("create version table: %v", err)
|
|
}
|
|
body, err := migrations.ReadFile("migrations/0001_bookmarks.sql")
|
|
if err != nil {
|
|
t.Fatalf("read 0001: %v", err)
|
|
}
|
|
if err := applyMigration(db, 1, string(body)); err != nil {
|
|
t.Fatalf("apply 0001: %v", err)
|
|
}
|
|
if _, err := db.Exec(`
|
|
INSERT INTO bookmarks (key, site, series_id, title, series_url, cover,
|
|
last_chapter, last_chapter_num, last_chapter_url, favorite, latest_chapter,
|
|
latest_chapter_num, latest_checked_at, status, kind, updated_at)
|
|
VALUES ('asura:solo', 'asura', 'solo', 'Solo Leveling',
|
|
'https://asurascans.com/comics/solo', 'https://asurascans.com/covers/solo.jpg',
|
|
'Chapter 10', 10, 'https://asurascans.com/comics/solo/ch/10', true,
|
|
'Chapter 11', 11, 123456, 'reading', 'manga', 1000)`); err != nil {
|
|
t.Fatalf("seed legacy row: %v", err)
|
|
}
|
|
|
|
// Bring it current through the production path: Open runs the schema to
|
|
// 0003, seeds the owner, then applies 0004 which attaches this row. 0002
|
|
// must have backfilled the series row, not lost data.
|
|
st, err := Open(url, testOwner, t.TempDir(), testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open after migrate: %v", err)
|
|
}
|
|
defer st.Close()
|
|
var (
|
|
title string
|
|
checked int64
|
|
fav bool
|
|
lastNum float64
|
|
)
|
|
if err := db.QueryRow(`SELECT title, latest_checked_at FROM series
|
|
WHERE site = 'asura' AND series_id = 'solo'`).Scan(&title, &checked); err != nil {
|
|
t.Fatalf("series row missing after migration: %v", err)
|
|
}
|
|
if title != "Solo Leveling" || checked != 123456 {
|
|
t.Fatalf("series = (%q, %d), want backfilled title and latest_checked_at", title, checked)
|
|
}
|
|
// The key column is gone; the bookmark is read by its composite key.
|
|
if err := db.QueryRow(`SELECT favorite, last_chapter_num FROM bookmarks
|
|
WHERE reader_id = $1 AND site = 'asura' AND series_id = 'solo'`,
|
|
st.OwnerID()).Scan(&fav, &lastNum); err != nil {
|
|
t.Fatalf("bookmark row missing after migration: %v", err)
|
|
}
|
|
if !fav || lastNum != 10 {
|
|
t.Fatalf("bookmark = (%v, %v), want favorite and progress kept", fav, lastNum)
|
|
}
|
|
}
|
|
|
|
func TestMigration0008DropsLegacyCoverRows(t *testing.T) {
|
|
db, err := sql.Open("pgx", pgtest.URL(t))
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := migrate(db, readersMigration); err != nil {
|
|
t.Fatalf("migrate readers: %v", err)
|
|
}
|
|
if err := seedOwner(db, testOwner); err != nil {
|
|
t.Fatalf("seed owner: %v", err)
|
|
}
|
|
if err := migrate(db, 7); err != nil {
|
|
t.Fatalf("migrate legacy covers: %v", err)
|
|
}
|
|
if _, err := db.Exec(`
|
|
INSERT INTO covers (image_id, body, content_type)
|
|
VALUES ('legacy-image', 'legacy-bytes', 'image/jpeg')`); err != nil {
|
|
t.Fatalf("seed legacy cover: %v", err)
|
|
}
|
|
if err := migrate(db, 0); err != nil {
|
|
t.Fatalf("migrate filesystem covers: %v", err)
|
|
}
|
|
|
|
var count int
|
|
if err := db.QueryRow(`SELECT count(*) FROM covers`).Scan(&count); err != nil {
|
|
t.Fatalf("count covers: %v", err)
|
|
}
|
|
if count != 0 {
|
|
t.Fatalf("legacy covers = %d, want 0", count)
|
|
}
|
|
var bodyColumn int
|
|
if err := db.QueryRow(`SELECT count(*) FROM information_schema.columns
|
|
WHERE table_name = 'covers' AND column_name = 'body'`).Scan(&bodyColumn); err != nil {
|
|
t.Fatalf("cover columns: %v", err)
|
|
}
|
|
if bodyColumn != 0 {
|
|
t.Fatal("legacy covers table still has body column")
|
|
}
|
|
}
|
|
|
|
// readSeries reads the series row directly, for asserting on what Upsert
|
|
// actually stored rather than what the joined Bookmark reports.
|
|
func readSeries(t *testing.T, s *Store, site, seriesID string) Series {
|
|
t.Helper()
|
|
sr, err := scanSeries(s.db.QueryRow(
|
|
`SELECT `+seriesColumns+`, 0 AS reader_count FROM series s
|
|
WHERE s.site = $1 AND s.series_id = $2`, site, seriesID).Scan)
|
|
if err != nil {
|
|
t.Fatalf("read series %s:%s: %v", site, seriesID, err)
|
|
}
|
|
return sr
|
|
}
|
|
|
|
// The first PUT for a series creates its row from the client's title and URL —
|
|
// there is no other source for them (ADR-0003). The Cover is not among them:
|
|
// it is acquired server-side, so a client-supplied one is dropped even on a
|
|
// brand-new row (ADR-0007).
|
|
func TestUpsertCreatesSeriesFromClient(t *testing.T) {
|
|
store := newTestStore(t)
|
|
stored, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
|
Cover: "https://asurascans.com/covers/solo.jpg", Kind: KindManga,
|
|
UpdatedAt: 1000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if stored.Cover != "" {
|
|
t.Fatalf("Cover = %q, want empty — a client cover is never stored", stored.Cover)
|
|
}
|
|
|
|
sr := readSeries(t, store, "asura", "solo")
|
|
if sr.Title != "Solo Leveling" || sr.SeriesURL != "https://asurascans.com/comics/solo" {
|
|
t.Fatalf("series = %+v, want client title/url stored", sr)
|
|
}
|
|
if sr.Cover != "" {
|
|
t.Fatalf("series cover = %q, want empty", sr.Cover)
|
|
}
|
|
}
|
|
|
|
// The hook is what starts creation-time acquisition, so it must fire exactly
|
|
// once per Series — on the PUT that created it, and on no later one, whichever
|
|
// Reader sends it.
|
|
func TestOnSeriesCreatedFiresOnceForANewSeries(t *testing.T) {
|
|
store := newTestStore(t)
|
|
var created []Series
|
|
store.OnSeriesCreated = func(sr Series) { created = append(created, sr) }
|
|
|
|
b := Bookmark{
|
|
Key: "comix:solo", Site: "comix", SeriesID: "solo", Title: "Solo Leveling",
|
|
SeriesURL: "https://comix.to/series/solo", Kind: KindManga, UpdatedAt: 1000,
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), b); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
b.LastChapterNum = 12
|
|
b.UpdatedAt = 2000
|
|
if _, err := store.Upsert(store.OwnerID(), b); err != nil {
|
|
t.Fatalf("second Upsert: %v", err)
|
|
}
|
|
if _, err := store.Upsert(secondReader(t, store), b); err != nil {
|
|
t.Fatalf("second reader Upsert: %v", err)
|
|
}
|
|
|
|
if len(created) != 1 {
|
|
t.Fatalf("hook fired %d times, want 1: %+v", len(created), created)
|
|
}
|
|
if created[0].Site != "comix" || created[0].SeriesID != "solo" ||
|
|
created[0].SeriesURL != "https://comix.to/series/solo" {
|
|
t.Fatalf("hook got %+v, want the created series' identity and URL", created[0])
|
|
}
|
|
}
|
|
|
|
// Acquisition at creation and the poll both write covers, and whichever
|
|
// arrives second must leave the first one alone: a Cover is replaced by
|
|
// nothing short of the series row being rebuilt.
|
|
func TestSetSeriesCoverDoesNotOverwrite(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
first := "https://asurascans.com/covers/first.jpg"
|
|
if err := store.SetSeriesCover("asura", "solo", first, []byte("first"), "image/jpeg"); err != nil {
|
|
t.Fatalf("SetSeriesCover: %v", err)
|
|
}
|
|
if err := store.SetSeriesCover("asura", "solo", "https://asurascans.com/covers/second.jpg",
|
|
[]byte("second"), "image/jpeg"); err != nil {
|
|
t.Fatalf("second SetSeriesCover: %v", err)
|
|
}
|
|
|
|
got, ok, err := store.Get(store.OwnerID(), "asura:solo")
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get = %v, %v", ok, err)
|
|
}
|
|
if want := "https://bookmarks.test/covers/" + CoverAddress(first); got.Cover != want {
|
|
t.Fatalf("Cover = %q, want the first one %q", got.Cover, want)
|
|
}
|
|
}
|
|
|
|
// The address comes straight off a public request path, so anything that is
|
|
// not a stored address must be a miss rather than a filesystem lookup.
|
|
func TestCoverByAddress(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
source := "https://asurascans.com/covers/solo.jpg"
|
|
if err := store.SetSeriesCover("asura", "solo", source, []byte("bytes"), "image/jpeg"); err != nil {
|
|
t.Fatalf("SetSeriesCover: %v", err)
|
|
}
|
|
|
|
body, contentType, ok, err := store.CoverByAddress(CoverAddress(source))
|
|
if err != nil || !ok {
|
|
t.Fatalf("CoverByAddress = %v, %v", ok, err)
|
|
}
|
|
if string(body) != "bytes" || contentType != "image/jpeg" {
|
|
t.Fatalf("CoverByAddress = %q, %q, want the stored bytes", body, contentType)
|
|
}
|
|
|
|
for _, address := range []string{"", "../../etc/passwd", "ZZ" + CoverAddress(source)[2:],
|
|
CoverAddress("never stored")} {
|
|
_, _, ok, err := store.CoverByAddress(address)
|
|
if err != nil || ok {
|
|
t.Fatalf("CoverByAddress(%q) = %v, %v, want a clean miss", address, ok, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A PUT naming an existing series must not overwrite its title or URL: the row
|
|
// is shared, and those values are scraped page content (ADR-0003). An acquired
|
|
// Cover is likewise untouched by any client.
|
|
func TestUpsertExistingSeriesIgnoresClientTitleCoverURL(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
|
LastChapterNum: 10, UpdatedAt: 1000,
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
acquired := "https://asurascans.com/covers/solo.jpg"
|
|
if err := store.SetSeriesCover("asura", "solo", acquired, []byte("bytes"), "image/jpeg"); err != nil {
|
|
t.Fatalf("SetSeriesCover: %v", err)
|
|
}
|
|
|
|
// Same series, hostile/compromised values, real progress advance.
|
|
base.Title = "Scraped Rename"
|
|
base.SeriesURL = "https://evil.example/solo"
|
|
base.Cover = "https://evil.example/solo.jpg"
|
|
base.LastChapterNum = 11
|
|
got, err := store.Upsert(store.OwnerID(), base)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
|
|
if got.Title != "Solo Leveling" || got.SeriesURL != "https://asurascans.com/comics/solo" ||
|
|
got.Cover != wantCover {
|
|
t.Fatalf("stored = %+v, want original title/url/cover kept", got)
|
|
}
|
|
if got.LastChapterNum != 11 {
|
|
t.Fatalf("LastChapterNum = %v, want 11 — progress in the same request must still land", got.LastChapterNum)
|
|
}
|
|
}
|
|
|
|
// Kind and latest-chapter are last-write-wins even on an existing series: the
|
|
// poller and the userscript both report the latest chapter, and kind is only
|
|
// known to whichever client created the row.
|
|
func TestUpsertExistingSeriesAcceptsKindAndLatest(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
|
|
UpdatedAt: 1000,
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
num := 12.0
|
|
base.Kind = KindNovel
|
|
base.LatestChapter = "Chapter 12"
|
|
base.LatestChapterNum = &num
|
|
got, err := store.Upsert(store.OwnerID(), base)
|
|
if err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
if got.Kind != KindNovel || got.LatestChapter != "Chapter 12" ||
|
|
got.LatestChapterNum == nil || *got.LatestChapterNum != 12 {
|
|
t.Fatalf("stored = %+v, want kind and latest chapter updated", got)
|
|
}
|
|
}
|
|
|
|
// Deleting the last bookmark must leave the series row behind, so a later
|
|
// re-bookmark shows title and cover immediately instead of re-acquiring them.
|
|
func TestDeleteKeepsSeriesRow(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
acquired := "https://asurascans.com/covers/solo.jpg"
|
|
if err := store.SetSeriesCover("asura", "solo", acquired, []byte("bytes"), "image/jpeg"); err != nil {
|
|
t.Fatalf("SetSeriesCover: %v", err)
|
|
}
|
|
if err := store.Delete(store.OwnerID(), "asura:solo"); err != nil {
|
|
t.Fatalf("Delete: %v", err)
|
|
}
|
|
|
|
sr := readSeries(t, store, "asura", "solo")
|
|
if sr.Title != "Solo Leveling" {
|
|
t.Fatalf("series = %+v, want it kept after the last bookmark is deleted", sr)
|
|
}
|
|
|
|
// Re-bookmark with nothing but progress: the stored title/cover come back.
|
|
stored, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
LastChapterNum: 5, UpdatedAt: 2000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("re-upsert: %v", err)
|
|
}
|
|
wantCover := "https://bookmarks.test/covers/" + CoverAddress(acquired)
|
|
if stored.Title != "Solo Leveling" || stored.Cover != wantCover {
|
|
t.Fatalf("re-bookmark = %+v, want title/cover from the surviving series row", stored)
|
|
}
|
|
}
|
|
|
|
// seedSecondReader inserts an extra bookmark on an existing series, owned by a
|
|
// second reader. Two bookmarks can share a series only across readers now
|
|
// (issue #22); the due queue's reader-count ordering counts them all.
|
|
func seedSecondReader(t *testing.T, s *Store, key, site, seriesID string, updatedAt int64) {
|
|
t.Helper()
|
|
if _, err := s.Upsert(secondReader(t, s), Bookmark{
|
|
Key: key, Site: site, SeriesID: seriesID, UpdatedAt: updatedAt,
|
|
}); err != nil {
|
|
t.Fatalf("seed second reader %q: %v", key, err)
|
|
}
|
|
}
|
|
|
|
// The whole point of the split: a shared series is due once, ordered ahead of
|
|
// single-reader series by how many bookmarks reference it.
|
|
func TestDueForLatestCheckOrdersByReaderCountThenAge(t *testing.T) {
|
|
s := newTestStore(t)
|
|
// "pop" has two bookmarks but was checked most recently; "solo" has one and
|
|
// was checked long ago. Reader count must win over age.
|
|
seedForCheck(t, s, "asura:pop", "https://asurascans.com/comics/pop", 900)
|
|
seedSecondReader(t, s, "asura:pop:2", "asura", "pop", 1001)
|
|
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 100)
|
|
|
|
due, err := s.DueForLatestCheck(1000, 1000, nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 2 {
|
|
t.Fatalf("due = %d rows, want 2", len(due))
|
|
}
|
|
if due[0].Key() != "asura:pop" || due[1].Key() != "asura:solo" {
|
|
t.Fatalf("due order = %q, %q; want asura:pop (2 readers), asura:solo (1)",
|
|
due[0].Key(), due[1].Key())
|
|
}
|
|
}
|
|
|
|
// A series with no bookmarks must never appear in the due queue, and nothing
|
|
// in the store ever deletes it (see TestDeleteKeepsSeriesRow).
|
|
func TestDueForLatestCheckExcludesOrphanSeries(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:kept", "https://asurascans.com/comics/kept", 0)
|
|
if _, err := s.db.Exec(`
|
|
INSERT INTO series (site, series_id, title, series_url, cover, kind,
|
|
latest_chapter, latest_chapter_num, latest_checked_at)
|
|
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
|
|
'', 'manga', '', NULL, 0)`); err != nil {
|
|
t.Fatalf("seed orphan series: %v", err)
|
|
}
|
|
|
|
due, err := s.DueForLatestCheck(1000, 1000, nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 1 || due[0].Key() != "asura:kept" {
|
|
t.Fatalf("due = %+v, want only the bookmarked series", due)
|
|
}
|
|
var n int
|
|
if err := s.db.QueryRow(
|
|
`SELECT count(*) FROM series WHERE site = 'asura' AND series_id = 'orphan'`).Scan(&n); err != nil {
|
|
t.Fatalf("count orphan series: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("orphan series count = %d, want 1 (never deleted)", n)
|
|
}
|
|
}
|
|
|
|
// The seed must never multiply the owner row: reopening the same database with
|
|
// a different token hash refreshes the stored hash, not the row. That is what
|
|
// keeps the readers table at exactly one row across restarts and token
|
|
// rotations.
|
|
func TestSeedOwnerIdempotentAndRefreshesTokenHash(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
coverDir := t.TempDir()
|
|
first, err := Open(url, Owner{DiscordID: "owner", TokenHash: sha256.Sum256([]byte("hash-v1"))}, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
ownerID := first.OwnerID()
|
|
first.Close()
|
|
|
|
second, err := Open(url, Owner{DiscordID: "owner", TokenHash: sha256.Sum256([]byte("hash-v2"))}, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
defer second.Close()
|
|
if second.OwnerID() != ownerID {
|
|
t.Fatalf("owner id = %d after reopen, want %d (same row)", second.OwnerID(), ownerID)
|
|
}
|
|
var (
|
|
n int
|
|
hash []byte
|
|
)
|
|
if err := second.db.QueryRow(`SELECT count(*), (SELECT token_sha256 FROM readers LIMIT 1) FROM readers`).Scan(&n, &hash); err != nil {
|
|
t.Fatalf("read readers: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("readers count = %d, want 1", n)
|
|
}
|
|
want := sha256.Sum256([]byte("hash-v2"))
|
|
if !bytes.Equal(hash, want[:]) {
|
|
t.Fatalf("token hash = %x, want the refreshed sha256", hash)
|
|
}
|
|
}
|
|
|
|
// The upgrade path for a deployed database: bookmarks created before readers
|
|
// existed must all land on the seeded owner, the key column must be gone, and
|
|
// the same database must be able to hold two readers' bookmarks for one series.
|
|
func TestMigration0004AttachesBookmarksToOwner(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
db, err := sql.Open("pgx", url)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { db.Close() })
|
|
|
|
// A database at the state before #21 shipped: 0001 applied, bookmarks
|
|
// keyed by <site>:<series_id>, no series table. Rows land before 0002, the
|
|
// way a real deployment's data did.
|
|
if err := migrate(db, 1); err != nil {
|
|
t.Fatalf("migrate to 0001: %v", err)
|
|
}
|
|
for _, key := range []string{"asura:solo", "demonic:catastrophic-necromancer"} {
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
if _, err := db.Exec(`
|
|
INSERT INTO bookmarks (key, site, series_id, updated_at)
|
|
VALUES ($1, $2, $3, 1000)`, key, site, seriesID); err != nil {
|
|
t.Fatalf("seed legacy row %q: %v", key, err)
|
|
}
|
|
}
|
|
// 0002 backfills the series rows, as it did in the real upgrade.
|
|
if err := migrate(db, 2); err != nil {
|
|
t.Fatalf("migrate to 0002: %v", err)
|
|
}
|
|
|
|
st, err := Open(url, testOwner, t.TempDir(), testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
defer st.Close()
|
|
|
|
var (
|
|
attached int
|
|
readers int
|
|
)
|
|
if err := st.db.QueryRow(
|
|
`SELECT count(*) FROM bookmarks WHERE reader_id = $1`, st.OwnerID()).Scan(&attached); err != nil {
|
|
t.Fatalf("count attached bookmarks: %v", err)
|
|
}
|
|
if attached != 2 {
|
|
t.Fatalf("bookmarks attached to owner = %d, want all 2", attached)
|
|
}
|
|
if err := st.db.QueryRow(`SELECT count(*) FROM readers`).Scan(&readers); err != nil {
|
|
t.Fatalf("count readers: %v", err)
|
|
}
|
|
if readers != 1 {
|
|
t.Fatalf("readers = %d, want 1", readers)
|
|
}
|
|
// The surrogate key column is gone; only the composite key remains.
|
|
if _, err := st.db.Query(`SELECT key FROM bookmarks`); err == nil {
|
|
t.Fatal("bookmarks.key still exists after the migration")
|
|
}
|
|
}
|
|
|
|
// One Reader and Series pair must admit at most one bookmark, enforced by the
|
|
// primary key itself — a raw INSERT that skips the upsert must fail.
|
|
func TestBookmarkDuplicateImpossibleAtDatabaseLevel(t *testing.T) {
|
|
st := newTestStore(t)
|
|
// A series row on its own, no bookmark: the raw inserts below must only
|
|
// ever collide on the bookmark primary key.
|
|
if _, err := st.db.Exec(
|
|
`INSERT INTO series (site, series_id) VALUES ('asura', 'solo')`); err != nil {
|
|
t.Fatalf("seed series: %v", err)
|
|
}
|
|
insert := func() error {
|
|
_, err := st.db.Exec(`
|
|
INSERT INTO bookmarks (reader_id, site, series_id, updated_at)
|
|
VALUES ($1, 'asura', 'solo', 1000)`, st.OwnerID())
|
|
return err
|
|
}
|
|
if err := insert(); err != nil {
|
|
t.Fatalf("first insert: %v", err)
|
|
}
|
|
if err := insert(); err == nil {
|
|
t.Fatal("duplicate bookmark for the same reader and series was accepted")
|
|
}
|
|
}
|
|
|
|
// Every read and write is scoped to the reader it names: a second reader sees
|
|
// an empty list, cannot read or delete the owner's row, and a delete by the
|
|
// wrong reader leaves the row alone.
|
|
func TestStoreScopesBookmarksToReader(t *testing.T) {
|
|
st := newTestStore(t)
|
|
other := secondReader(t, st)
|
|
if _, err := st.Upsert(st.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed owner bookmark: %v", err)
|
|
}
|
|
|
|
otherList, err := st.List(other)
|
|
if err != nil {
|
|
t.Fatalf("List(other): %v", err)
|
|
}
|
|
if len(otherList) != 0 {
|
|
t.Fatalf("other reader's list = %+v, want empty", otherList)
|
|
}
|
|
if _, ok, err := st.Get(other, "asura:solo"); err != nil || ok {
|
|
t.Fatalf("Get(other, asura:solo) = ok:%v err:%v, want not found", ok, err)
|
|
}
|
|
if err := st.Delete(other, "asura:solo"); err != nil {
|
|
t.Fatalf("Delete(other): %v", err)
|
|
}
|
|
ownerList, err := st.List(st.OwnerID())
|
|
if err != nil {
|
|
t.Fatalf("List(owner): %v", err)
|
|
}
|
|
if len(ownerList) != 1 || ownerList[0].Key != "asura:solo" {
|
|
t.Fatalf("owner's list after other's delete = %+v, want the row intact", ownerList)
|
|
}
|
|
|
|
// The same key under a second reader is an independent bookmark.
|
|
if _, err := st.Upsert(other, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
|
|
}); err != nil {
|
|
t.Fatalf("upsert other's bookmark: %v", err)
|
|
}
|
|
if got, err := st.List(other); err != nil || len(got) != 1 {
|
|
t.Fatalf("other's list after own upsert = %+v err:%v, want 1 row", got, err)
|
|
}
|
|
}
|
|
|
|
// Deleting a reader must take their bookmarks with them (ON DELETE CASCADE)
|
|
// while leaving the shared series row behind.
|
|
func TestDeleteReaderCascadesToBookmarks(t *testing.T) {
|
|
st := newTestStore(t)
|
|
other := secondReader(t, st)
|
|
if _, err := st.Upsert(other, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed other's bookmark: %v", err)
|
|
}
|
|
|
|
if _, err := st.db.Exec(`DELETE FROM readers WHERE id = $1`, other); err != nil {
|
|
t.Fatalf("delete reader: %v", err)
|
|
}
|
|
var n int
|
|
if err := st.db.QueryRow(`SELECT count(*) FROM bookmarks`).Scan(&n); err != nil {
|
|
t.Fatalf("count bookmarks: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Fatalf("bookmarks after reader delete = %d, want 0 (cascade)", n)
|
|
}
|
|
sr := readSeries(t, st, "asura", "solo")
|
|
if sr.Title != "Solo Leveling" {
|
|
t.Fatalf("series = %+v, want it kept after its only reader was deleted", sr)
|
|
}
|
|
}
|
|
|
|
// Registration is one code path: the first sight of a Discord identity creates
|
|
// the Reader, every later one returns the same row. The epoch-0 hash argument
|
|
// is for creation only — a returning Reader who has rotated must not have that
|
|
// rotation undone by logging in again.
|
|
func TestEnsureReaderCreatesOnceAndNeverClobbersARotation(t *testing.T) {
|
|
s := newTestStore(t)
|
|
first, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
|
if err != nil {
|
|
t.Fatalf("EnsureReader: %v", err)
|
|
}
|
|
if first == s.OwnerID() {
|
|
t.Fatal("a new Discord identity resolved to the owner Reader")
|
|
}
|
|
if id, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil || !ok || id != first {
|
|
t.Fatalf("new Reader's credential resolved to (%d, %v, %v), want (%d, true, nil)", id, ok, err, first)
|
|
}
|
|
|
|
rotated := sha256.Sum256([]byte("cred-epoch-1"))
|
|
if err := s.RotateToken(first, 0, rotated); err != nil {
|
|
t.Fatalf("RotateToken: %v", err)
|
|
}
|
|
|
|
again, err := s.EnsureReader("new-member", sha256.Sum256([]byte("cred-epoch-0")))
|
|
if err != nil {
|
|
t.Fatalf("second EnsureReader: %v", err)
|
|
}
|
|
if again != first {
|
|
t.Fatalf("second login returned Reader %d, want the existing %d", again, first)
|
|
}
|
|
if _, ok, err := s.ReaderIDForTokenHash(sha256.Sum256([]byte("cred-epoch-0"))); err != nil {
|
|
t.Fatalf("stale lookup: %v", err)
|
|
} else if ok {
|
|
t.Fatal("logging in again revived the pre-rotation credential")
|
|
}
|
|
if id, ok, err := s.ReaderIDForTokenHash(rotated); err != nil || !ok || id != first {
|
|
t.Fatalf("rotated credential resolved to (%d, %v, %v), want the same Reader", id, ok, err)
|
|
}
|
|
|
|
// Signing in as the owner's own Discord identity reuses the seeded row
|
|
// rather than minting a duplicate library.
|
|
if id, err := s.EnsureReader(testOwner.DiscordID, sha256.Sum256([]byte("ignored"))); err != nil {
|
|
t.Fatalf("EnsureReader(owner): %v", err)
|
|
} else if id != s.OwnerID() {
|
|
t.Fatalf("owner login returned Reader %d, want the seeded owner %d", id, s.OwnerID())
|
|
}
|
|
}
|
|
|
|
// The owner's administration view: who exists and how many live sessions each
|
|
// holds. Revocation drops all of one Reader's sessions and nobody else's.
|
|
func TestReadersAndSessionRevocation(t *testing.T) {
|
|
s := newTestStore(t)
|
|
other := secondReader(t, s)
|
|
for _, id := range []string{"own-1", "own-2"} {
|
|
if _, err := s.CreateSession(id, s.OwnerID(), time.Hour); err != nil {
|
|
t.Fatalf("CreateSession(%s): %v", id, err)
|
|
}
|
|
}
|
|
if _, err := s.CreateSession("other-1", other, time.Hour); err != nil {
|
|
t.Fatalf("CreateSession(other): %v", err)
|
|
}
|
|
// An expired row must not be counted as a session the owner can revoke.
|
|
if _, err := s.CreateSession("other-dead", other, -time.Minute); err != nil {
|
|
t.Fatalf("CreateSession(expired): %v", err)
|
|
}
|
|
|
|
readers, err := s.Readers()
|
|
if err != nil {
|
|
t.Fatalf("Readers: %v", err)
|
|
}
|
|
if len(readers) != 2 || readers[0].ID != s.OwnerID() || readers[1].ID != other {
|
|
t.Fatalf("readers = %+v, want the owner then the second Reader", readers)
|
|
}
|
|
if readers[0].DiscordID != testOwner.DiscordID {
|
|
t.Fatalf("owner discord id = %q, want %q", readers[0].DiscordID, testOwner.DiscordID)
|
|
}
|
|
if readers[0].Sessions != 2 || readers[1].Sessions != 1 {
|
|
t.Fatalf("session counts = %d, %d; want 2 and 1 live", readers[0].Sessions, readers[1].Sessions)
|
|
}
|
|
|
|
if err := s.DeleteReaderSessions(other); err != nil {
|
|
t.Fatalf("DeleteReaderSessions: %v", err)
|
|
}
|
|
if _, ok, err := s.GetSession("other-1", time.Now()); err != nil || ok {
|
|
t.Fatalf("revoked session still resolves: ok=%v err=%v", ok, err)
|
|
}
|
|
if _, ok, err := s.GetSession("own-1", time.Now()); err != nil || !ok {
|
|
t.Fatalf("owner's session was collateral: ok=%v err=%v", ok, err)
|
|
}
|
|
}
|
|
|
|
// Two Readers on one Series: one series row, two independent progresses. The
|
|
// second Reader starts at zero however far the first has read, and the shared
|
|
// row is still due exactly once.
|
|
func TestTwoReadersShareOneSeriesWithIndependentProgress(t *testing.T) {
|
|
s := newTestStore(t)
|
|
other := secondReader(t, s)
|
|
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
|
LastChapter: "Chapter 200", LastChapterNum: 200, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed owner: %v", err)
|
|
}
|
|
theirs, err := s.Upsert(other, Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", UpdatedAt: 2000,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("seed other: %v", err)
|
|
}
|
|
if theirs.LastChapterNum != 0 || theirs.LastChapter != "" {
|
|
t.Fatalf("second Reader's progress = %+v, want zero regardless of the first's 200", theirs)
|
|
}
|
|
// The shared facts are still shared: the series row it joined to is the
|
|
// one the first Reader created.
|
|
if theirs.Title != "Solo Leveling" {
|
|
t.Fatalf("second Reader's title = %q, want the shared series title", theirs.Title)
|
|
}
|
|
var series int
|
|
if err := s.db.QueryRow(`SELECT count(*) FROM series`).Scan(&series); err != nil {
|
|
t.Fatalf("count series: %v", err)
|
|
}
|
|
if series != 1 {
|
|
t.Fatalf("series rows = %d, want 1 shared row for two bookmarks", series)
|
|
}
|
|
|
|
due, err := s.DueForLatestCheck(time.Now().UnixMilli(), time.Now().UnixMilli(), nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
|
t.Fatalf("due = %+v, want the shared series exactly once per cycle", due)
|
|
}
|
|
|
|
// One Reader dropping their bookmark leaves the other's intact and the
|
|
// series still polled.
|
|
if err := s.Delete(other, "asura:solo"); err != nil {
|
|
t.Fatalf("Delete(other): %v", err)
|
|
}
|
|
if b, ok, err := s.Get(s.OwnerID(), "asura:solo"); err != nil || !ok || b.LastChapterNum != 200 {
|
|
t.Fatalf("owner's bookmark after the other's delete = %+v ok=%v err=%v, want it intact", b, ok, err)
|
|
}
|
|
due, err = s.DueForLatestCheck(time.Now().UnixMilli(), time.Now().UnixMilli(), nil, 10)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck after delete: %v", err)
|
|
}
|
|
if len(due) != 1 || due[0].Key() != "asura:solo" {
|
|
t.Fatalf("due after one Reader left = %+v, want the series still polled", due)
|
|
}
|
|
}
|
|
func TestCoverPersistsAcrossReopen(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
coverDir := t.TempDir()
|
|
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
body := []byte("stored-cover")
|
|
const sourceURL = "https://cdn.example/covers/series.jpg"
|
|
if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
|
|
t.Fatalf("PutCover: %v", err)
|
|
}
|
|
if err := first.Close(); err != nil {
|
|
t.Fatalf("close first store: %v", err)
|
|
}
|
|
|
|
second, err := Open(url, testOwner, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("reopen: %v", err)
|
|
}
|
|
defer second.Close()
|
|
got, contentType, ok, err := second.GetCover(sourceURL)
|
|
if err != nil {
|
|
t.Fatalf("GetCover: %v", err)
|
|
}
|
|
if !ok || !bytes.Equal(got, body) || contentType != "image/webp" {
|
|
t.Fatalf("stored cover = (%q, %q, %v), want (%q, image/webp, true)", got, contentType, ok, body)
|
|
}
|
|
}
|
|
|
|
func TestOpenRequiresCoverDirectory(t *testing.T) {
|
|
if _, err := Open(pgtest.URL(t), testOwner, "", testCoverBaseURL); err == nil || !strings.Contains(err.Error(), "cover directory is required") {
|
|
t.Fatalf("Open without cover directory = %v, want required-directory error", err)
|
|
}
|
|
}
|
|
|
|
// A base URL without a scheme reads like a hostname and starts cleanly, but
|
|
// every Cover it puts on the wire is an address no browser can resolve.
|
|
func TestOpenRequiresAbsoluteCoverBaseURL(t *testing.T) {
|
|
for _, base := range []string{"", "bookmarks.test", "https://", "ftp://bookmarks.test"} {
|
|
if _, err := Open(pgtest.URL(t), testOwner, t.TempDir(), base); err == nil ||
|
|
!strings.Contains(err.Error(), "absolute http(s) origin") {
|
|
t.Fatalf("Open with base %q = %v, want absolute-origin error", base, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCoverIsContentAddressedOnFilesystem(t *testing.T) {
|
|
url := pgtest.URL(t)
|
|
coverDir := t.TempDir()
|
|
first, err := Open(url, testOwner, coverDir, testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open: %v", err)
|
|
}
|
|
body := []byte("stored-cover")
|
|
const sourceURL = "https://cdn.example/covers/series.jpg"
|
|
if err := first.PutCover(sourceURL, body, "image/webp"); err != nil {
|
|
first.Close()
|
|
t.Fatalf("PutCover: %v", err)
|
|
}
|
|
defer first.Close()
|
|
|
|
addressBytes := sha256.Sum256([]byte(sourceURL))
|
|
address := hex.EncodeToString(addressBytes[:])
|
|
wantPath := filepath.Join(address[:2], address[2:4], address)
|
|
|
|
var gotPath, contentType string
|
|
if err := first.db.QueryRow(`SELECT path, content_type FROM covers WHERE address = $1`, address).Scan(&gotPath, &contentType); err != nil {
|
|
t.Fatalf("cover row: %v", err)
|
|
}
|
|
if gotPath != wantPath || contentType != "image/webp" {
|
|
t.Fatalf("cover row = (%q, %q), want (%q, image/webp)", gotPath, contentType, wantPath)
|
|
}
|
|
if got, err := os.ReadFile(filepath.Join(coverDir, gotPath)); err != nil || !bytes.Equal(got, body) {
|
|
t.Fatalf("cover file = (%q, %v), want (%q, nil)", got, err, body)
|
|
}
|
|
|
|
var bodyColumn int
|
|
if err := first.db.QueryRow(`SELECT count(*) FROM information_schema.columns WHERE table_name = 'covers' AND column_name = 'body'`).Scan(&bodyColumn); err != nil {
|
|
t.Fatalf("cover columns: %v", err)
|
|
}
|
|
if bodyColumn != 0 {
|
|
t.Fatalf("covers still has body column")
|
|
}
|
|
}
|
|
|
|
func TestCoverStoreAcceptsAnySourceURL(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const sourceURL = "https://cdn.example/covers/series.jpg"
|
|
want := []byte("cover-bytes")
|
|
if err := s.PutCover(sourceURL, want, "image/jpeg"); err != nil {
|
|
t.Fatalf("PutCover: %v", err)
|
|
}
|
|
got, contentType, ok, err := s.GetCover(sourceURL)
|
|
if err != nil {
|
|
t.Fatalf("GetCover: %v", err)
|
|
}
|
|
if !ok || !bytes.Equal(got, want) || contentType != "image/jpeg" {
|
|
t.Fatalf("GetCover = (%q, %q, %v), want (%q, image/jpeg, true)", got, contentType, ok, want)
|
|
}
|
|
if err := s.PutCover("https://cdn.example/not-image", []byte("html"), "text/html"); err == nil {
|
|
t.Fatal("PutCover accepted a non-image")
|
|
}
|
|
if _, _, ok, err := s.GetCover("https://cdn.example/not-image"); err != nil || ok {
|
|
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
|
|
}
|
|
}
|