2182 lines
77 KiB
Go
2182 lines
77 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)
|
|
}
|
|
}
|
|
|
|
// noCeiling is a Sighting deferral ceiling no Series can reach, for the tests
|
|
// that predate the ceiling and are about rest, ordering or buckets instead.
|
|
const noCeiling = int64(-1)
|
|
|
|
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("asura", now-hour, noCeiling)
|
|
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 TestDueForLatestCheckOldestFirstAndScopedToSite(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)
|
|
// A second Site's due series must not appear in asura's list: each Lane
|
|
// asks for one Site, and no Lane may see another's queue.
|
|
seedForCheck(t, s, "demonic:z", "https://demonicscans.org/manga/z", 0)
|
|
|
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 3 {
|
|
t.Fatalf("got %d rows, want 3 (all of asura's, none of demonic's)", len(due))
|
|
}
|
|
if due[0].Key() != "asura:a" || due[1].Key() != "asura:b" || due[2].Key() != "asura:c" {
|
|
t.Fatalf("got %q,%q,%q; want asura:a,asura:b,asura:c (oldest first)", due[0].Key(), due[1].Key(), due[2].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("asura", time.Now().UnixMilli(), noCeiling)
|
|
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")
|
|
}
|
|
}
|
|
|
|
// The gap's denominator counts every Series the Lane will ever Poll: a
|
|
// finished Series must not make the Lane faster than it needs to be, and
|
|
// another Site's Series must not leak into this Site's count.
|
|
func TestEligibleSeriesCount(t *testing.T) {
|
|
store := newTestStore(t)
|
|
seedForCheck(t, store, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
|
seedForCheck(t, store, "asura:archived", "https://asurascans.com/comics/archived", 0)
|
|
if _, err := store.Upsert(store.OwnerID(), Bookmark{
|
|
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
|
SeriesURL: "https://asurascans.com/comics/finished",
|
|
Status: StatusFinished, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed finished: %v", err)
|
|
}
|
|
seedForCheck(t, store, "demonic:z", "https://demonicscans.org/manga/z", 0)
|
|
|
|
n, err := store.EligibleSeriesCount("asura")
|
|
if err != nil {
|
|
t.Fatalf("EligibleSeriesCount: %v", err)
|
|
}
|
|
if n != 2 {
|
|
t.Fatalf("eligible = %d, want 2 (finished excluded, demonic excluded)", n)
|
|
}
|
|
n, err = store.EligibleSeriesCount("demonic")
|
|
if err != nil {
|
|
t.Fatalf("EligibleSeriesCount(demonic): %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Fatalf("eligible(demonic) = %d, want 1", n)
|
|
}
|
|
}
|
|
|
|
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+`, false AS forced, 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/" + CoverAddressForBytes([]byte("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(CoverAddressForBytes([]byte("bytes")))
|
|
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" + CoverAddressForBytes([]byte("bytes"))[2:],
|
|
CoverAddressForBytes([]byte("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/" + CoverAddressForBytes([]byte("bytes"))
|
|
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/" + CoverAddressForBytes([]byte("bytes"))
|
|
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("asura", 1000, noCeiling)
|
|
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("asura", 1000, noCeiling)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The Sighting counters ship before the mechanism that fills them (issue
|
|
// #102 before #103), so the admin page depends on their default: a fresh
|
|
// Reader reads back trusted. Marking one directly proves Readers() reports
|
|
// the counters and Blocked() flips at the limit, and that ClearReaderMarks —
|
|
// the owner's remedy for a false mark — zeroes them again.
|
|
func TestReaderSightingMarks(t *testing.T) {
|
|
s := newTestStore(t)
|
|
other := secondReader(t, s)
|
|
|
|
readers, err := s.Readers()
|
|
if err != nil {
|
|
t.Fatalf("Readers: %v", err)
|
|
}
|
|
if len(readers) != 2 {
|
|
t.Fatalf("readers = %d, want the owner and the second Reader", len(readers))
|
|
}
|
|
for _, r := range readers {
|
|
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
|
t.Fatalf("fresh reader %d has marks: %+v", r.ID, r)
|
|
}
|
|
}
|
|
|
|
// The counters' default is the trusted state; writing them directly is
|
|
// the only way to exercise the read path until issue #103 moves them.
|
|
if _, err := s.db.Exec(`
|
|
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
|
WHERE id = $1`, other); err != nil {
|
|
t.Fatalf("mark reader: %v", err)
|
|
}
|
|
readers, err = s.Readers()
|
|
if err != nil {
|
|
t.Fatalf("Readers: %v", err)
|
|
}
|
|
var marked *ReaderSummary
|
|
for i := range readers {
|
|
if readers[i].ID == other {
|
|
marked = &readers[i]
|
|
}
|
|
}
|
|
if marked == nil || marked.Agreements != 5 || marked.Disagreements != 2 {
|
|
t.Fatalf("marked reader = %+v, want agreements 5, disagreements 2", marked)
|
|
}
|
|
if marked.Blocked() {
|
|
t.Fatalf("reader with 2 disagreements is blocked; limit is %d", SightingDisagreementLimit)
|
|
}
|
|
|
|
if _, err := s.db.Exec(`
|
|
UPDATE readers SET sighting_disagreements = 3 WHERE id = $1`, other); err != nil {
|
|
t.Fatalf("block reader: %v", err)
|
|
}
|
|
readers, err = s.Readers()
|
|
if err != nil {
|
|
t.Fatalf("Readers: %v", err)
|
|
}
|
|
for _, r := range readers {
|
|
if r.ID == other && !r.Blocked() {
|
|
t.Fatalf("reader at the disagreement limit is not blocked: %+v", r)
|
|
}
|
|
if r.ID == s.OwnerID() && r.Blocked() {
|
|
t.Fatalf("untouched owner became blocked: %+v", r)
|
|
}
|
|
}
|
|
|
|
if err := s.ClearReaderMarks(other); err != nil {
|
|
t.Fatalf("ClearReaderMarks: %v", err)
|
|
}
|
|
if err := s.ClearReaderMarks(other + 9999); err != nil {
|
|
t.Fatalf("ClearReaderMarks(unknown id): %v", err)
|
|
}
|
|
readers, err = s.Readers()
|
|
if err != nil {
|
|
t.Fatalf("Readers: %v", err)
|
|
}
|
|
for _, r := range readers {
|
|
if r.Agreements != 0 || r.Disagreements != 0 || r.Blocked() {
|
|
t.Fatalf("reader %d not cleared: %+v", r.ID, r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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("asura", time.Now().UnixMilli(), noCeiling)
|
|
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("asura", time.Now().UnixMilli(), noCeiling)
|
|
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.CoverByAddress(CoverAddressForBytes(body))
|
|
if err != nil {
|
|
t.Fatalf("CoverByAddress: %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(body)
|
|
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.CoverByAddress(CoverAddressForBytes(want))
|
|
if err != nil {
|
|
t.Fatalf("CoverByAddress: %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.CoverByAddress(CoverAddressForBytes([]byte("html"))); err != nil || ok {
|
|
t.Fatalf("rejected cover = found %v, err %v; want missing", ok, err)
|
|
}
|
|
}
|
|
func TestRecordLanePassPrunesBeforeInsertCutoff(t *testing.T) {
|
|
s := newTestStore(t)
|
|
for _, pass := range []LanePass{
|
|
{Site: "asura", RanAt: 99},
|
|
{Site: "asura", RanAt: 100},
|
|
} {
|
|
if err := s.RecordLanePass(pass, 100); err != nil {
|
|
t.Fatalf("RecordLanePass(%d): %v", pass.RanAt, err)
|
|
}
|
|
}
|
|
|
|
if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 200}, 100); err != nil {
|
|
t.Fatalf("RecordLanePass(200): %v", err)
|
|
}
|
|
var count int
|
|
if err := s.db.QueryRow(`SELECT count(*) FROM poll_passes WHERE site = $1`, "asura").Scan(&count); err != nil {
|
|
t.Fatalf("count passes: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Fatalf("retained passes = %d, want 2", count)
|
|
}
|
|
if _, ok, err := s.LatestLanePass("asura"); err != nil || !ok {
|
|
t.Fatalf("LatestLanePass = ok %v, err %v; want latest row", ok, err)
|
|
}
|
|
}
|
|
|
|
func TestLatestLanePassesKeepsNewestPerSiteAndJoinsState(t *testing.T) {
|
|
s := newTestStore(t)
|
|
for _, pass := range []LanePass{
|
|
{Site: "asura", RanAt: 100, Due: 1},
|
|
{Site: "asura", RanAt: 200, Skip: "due-query", Due: 2, Checked: 3, GapMS: 4000, Clamped: true},
|
|
{Site: "demonic", RanAt: 150, Due: 4},
|
|
} {
|
|
if err := s.RecordLanePass(pass, -1); err != nil {
|
|
t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err)
|
|
}
|
|
}
|
|
if err := s.PauseLane("asura", 1234); err != nil {
|
|
t.Fatalf("PauseLane: %v", err)
|
|
}
|
|
if err := s.SetLaneRefusal("asura", 5678); err != nil {
|
|
t.Fatalf("SetLaneRefusal: %v", err)
|
|
}
|
|
|
|
got, err := s.LatestLanePasses()
|
|
if err != nil {
|
|
t.Fatalf("LatestLanePasses: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("latest passes = %d, want one per Site", len(got))
|
|
}
|
|
bySite := map[string]LanePass{}
|
|
for _, pass := range got {
|
|
bySite[pass.Site] = pass
|
|
}
|
|
asura := bySite["asura"]
|
|
if asura.RanAt != 200 || asura.Skip != "due-query" || asura.Due != 2 || asura.Checked != 3 || asura.GapMS != 4000 || !asura.Clamped ||
|
|
asura.PausedUntil != 1234 || asura.RefuseUntil != 5678 {
|
|
t.Fatalf("asura latest pass = %+v, want newest pass and joined state", asura)
|
|
}
|
|
if demonic := bySite["demonic"]; demonic.RanAt != 150 || demonic.Due != 4 {
|
|
t.Fatalf("demonic latest pass = %+v, want its only pass", demonic)
|
|
}
|
|
}
|
|
|
|
func TestLanePassOutcomesSumsWindow(t *testing.T) {
|
|
s := newTestStore(t)
|
|
for _, pass := range []LanePass{
|
|
{Site: "asura", RanAt: 99, Refused: 1, Unreachable: 2, NoChapter: 3, Unfetchable: 4, Errors: 5},
|
|
{Site: "asura", RanAt: 100, Refused: 2, Unreachable: 3, NoChapter: 4, Unfetchable: 5, Errors: 6},
|
|
{Site: "asura", RanAt: 200, Refused: 3, Unreachable: 4, NoChapter: 5, Unfetchable: 6, Errors: 7},
|
|
{Site: "demonic", RanAt: 150, Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12},
|
|
} {
|
|
if err := s.RecordLanePass(pass, -1); err != nil {
|
|
t.Fatalf("RecordLanePass(%s/%d): %v", pass.Site, pass.RanAt, err)
|
|
}
|
|
}
|
|
|
|
got, err := s.LanePassOutcomes(100)
|
|
if err != nil {
|
|
t.Fatalf("LanePassOutcomes: %v", err)
|
|
}
|
|
if len(got) != 2 {
|
|
t.Fatalf("outcome Sites = %d, want 2", len(got))
|
|
}
|
|
bySite := map[string]SiteOutcomes{}
|
|
for _, outcomes := range got {
|
|
bySite[outcomes.Site] = outcomes
|
|
}
|
|
if want := (SiteOutcomes{Site: "asura", Refused: 5, Unreachable: 7, NoChapter: 9, Unfetchable: 11, Errors: 13}); bySite["asura"] != want {
|
|
t.Fatalf("asura outcomes = %+v, want %+v", bySite["asura"], want)
|
|
}
|
|
if want := (SiteOutcomes{Site: "demonic", Refused: 8, Unreachable: 9, NoChapter: 10, Unfetchable: 11, Errors: 12}); bySite["demonic"] != want {
|
|
t.Fatalf("demonic outcomes = %+v, want %+v", bySite["demonic"], want)
|
|
}
|
|
}
|
|
|
|
func TestLaneGatesReadsOneRow(t *testing.T) {
|
|
s := newTestStore(t)
|
|
paused, refused, err := s.LaneGates("asura")
|
|
if err != nil || paused != 0 || refused != 0 {
|
|
t.Fatalf("LaneGates on a missing state row = (%d, %d, %v), want (0, 0, nil)", paused, refused, err)
|
|
}
|
|
if err := s.PauseLane("asura", 2000); err != nil {
|
|
t.Fatalf("PauseLane: %v", err)
|
|
}
|
|
if err := s.SetLaneRefusal("asura", 3000); err != nil {
|
|
t.Fatalf("SetLaneRefusal: %v", err)
|
|
}
|
|
paused, refused, err = s.LaneGates("asura")
|
|
if err != nil || paused != 2000 || refused != 3000 {
|
|
t.Fatalf("LaneGates = (%d, %d, %v), want (2000, 3000, nil)", paused, refused, err)
|
|
}
|
|
}
|
|
|
|
func TestLaneStatePauseResumeAndRefusal(t *testing.T) {
|
|
s := newTestStore(t)
|
|
for _, until := range []int64{0, -1} {
|
|
if err := s.PauseLane("asura", until); err == nil {
|
|
t.Fatalf("PauseLane(%d) accepted a non-future expiry", until)
|
|
}
|
|
}
|
|
if err := s.PauseLane("asura", 2000); err != nil {
|
|
t.Fatalf("PauseLane: %v", err)
|
|
}
|
|
if err := s.SetLaneRefusal("asura", 3000); err != nil {
|
|
t.Fatalf("SetLaneRefusal: %v", err)
|
|
}
|
|
if err := s.RecordLanePass(LanePass{Site: "asura", RanAt: 1}, -1); err != nil {
|
|
t.Fatalf("RecordLanePass: %v", err)
|
|
}
|
|
pausedUntil, refuseUntil, err := s.LaneGates("asura")
|
|
if err != nil || pausedUntil != 2000 || refuseUntil != 3000 {
|
|
t.Fatalf("LaneGates = %d/%d, %v; want 2000/3000", pausedUntil, refuseUntil, err)
|
|
}
|
|
paused, err := s.PausedLanes()
|
|
if err != nil {
|
|
t.Fatalf("PausedLanes: %v", err)
|
|
}
|
|
if len(paused) != 1 || paused[0] != (LanePause{Site: "asura", PausedUntil: 2000}) {
|
|
t.Fatalf("PausedLanes = %+v, want asura/2000", paused)
|
|
}
|
|
|
|
if err := s.ResumeLane("asura"); err != nil {
|
|
t.Fatalf("ResumeLane: %v", err)
|
|
}
|
|
latest, ok, err := s.LatestLanePass("asura")
|
|
if err != nil || !ok || latest.RefuseUntil != 3000 {
|
|
t.Fatalf("latest refusal after resume = %+v, ok=%v, err=%v; want 3000 preserved", latest, ok, err)
|
|
}
|
|
if got, got2, err := s.LaneGates("asura"); err != nil || got != 0 || got2 != 3000 {
|
|
t.Fatalf("LaneGates after resume = %d/%d, %v; want 0/3000", got, got2, err)
|
|
}
|
|
if paused, err := s.PausedLanes(); err != nil || len(paused) != 0 {
|
|
t.Fatalf("PausedLanes after resume = %+v, %v; want empty", paused, err)
|
|
}
|
|
var rows int
|
|
if err := s.db.QueryRow(`SELECT count(*) FROM poll_lanes WHERE site = $1`, "asura").Scan(&rows); err != nil {
|
|
t.Fatalf("count lane state: %v", err)
|
|
}
|
|
if rows != 1 {
|
|
t.Fatalf("lane state rows after resume = %d, want 1", rows)
|
|
}
|
|
}
|
|
|
|
// A forced Series is due ahead of the rest cutoff: the request overrides the
|
|
// rest gate so the Lane's next pass picks it up however recently it was
|
|
// checked. An unforced series under the rest stays out.
|
|
func TestDueForLatestCheckForcedOverridesRestCutoff(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const now = int64(10 * 3600_000)
|
|
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", now-30*60_000)
|
|
seedForCheck(t, s, "asura:fresh", "https://asurascans.com/comics/fresh", now-30*60_000)
|
|
if err := s.ForceSeriesPoll("asura", "forced", now); err != nil {
|
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
|
}
|
|
|
|
due, err := s.DueForLatestCheck("asura", now-3600_000, noCeiling)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 1 || due[0].Key() != "asura:forced" {
|
|
t.Fatalf("due = %v, want only the forced series", due)
|
|
}
|
|
}
|
|
|
|
// The Sighting-deferral clause holds a solitary series back after a recent
|
|
// Reader report; a forced request overrides it and puts the series back on
|
|
// the Lane's list.
|
|
func TestDueForLatestCheckForcedOverridesSightingDeferral(t *testing.T) {
|
|
s := newTestStore(t)
|
|
const now = int64(10 * 3600_000)
|
|
// One bookmark (so deferral can apply), sighted and checked 10m ago:
|
|
// inside the deferral window and under the ceiling.
|
|
seedForCheck(t, s, "asura:deferred", "https://asurascans.com/comics/deferred", now-10*60_000)
|
|
if _, err := s.db.Exec(
|
|
`UPDATE series SET latest_sighted_at = $1 WHERE site = 'asura' AND series_id = 'deferred'`,
|
|
now-10*60_000); err != nil {
|
|
t.Fatalf("seed sighting: %v", err)
|
|
}
|
|
|
|
// Unforced: deferred, and under the rest anyway.
|
|
due, err := s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 0 {
|
|
t.Fatalf("unforced deferred series is due: %v", due)
|
|
}
|
|
|
|
// Forced: the request overrides the deferral.
|
|
if err := s.ForceSeriesPoll("asura", "deferred", now); err != nil {
|
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
|
}
|
|
due, err = s.DueForLatestCheck("asura", now-3600_000, now-3*3600_000)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 1 || due[0].Key() != "asura:deferred" {
|
|
t.Fatalf("forced deferred series not due: %v", due)
|
|
}
|
|
}
|
|
|
|
// The finished-only bucket excludes a series whose only bookmarks are
|
|
// finished; a forced request overrides it — the owner asked, so the Lane
|
|
// looks.
|
|
func TestDueForLatestCheckForcedOverridesFinishedBucket(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:reading", "https://asurascans.com/comics/reading", 0)
|
|
if _, err := s.Upsert(s.OwnerID(), Bookmark{
|
|
Key: "asura:finished", Site: "asura", SeriesID: "finished",
|
|
SeriesURL: "https://asurascans.com/comics/finished",
|
|
Status: StatusFinished, UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed finished: %v", err)
|
|
}
|
|
|
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
for _, sr := range due {
|
|
if sr.Key() == "asura:finished" {
|
|
t.Fatalf("unforced finished series is due: %v", due)
|
|
}
|
|
}
|
|
|
|
if err := s.ForceSeriesPoll("asura", "finished", 5000); err != nil {
|
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
|
}
|
|
due, err = s.DueForLatestCheck("asura", 1000, noCeiling)
|
|
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:finished"] {
|
|
t.Fatalf("forced finished series not due: %v", due)
|
|
}
|
|
}
|
|
|
|
// A forced Series jumps the queue: it sorts ahead of a more-read series that
|
|
// is due by rest, without disturbing the reader-count-then-age tie-break
|
|
// among the unforced rows (ADR-0003).
|
|
func TestDueForLatestCheckForcedSortsFirst(t *testing.T) {
|
|
s := newTestStore(t)
|
|
// "popular" has two readers and is long overdue; "forced" has one reader
|
|
// and a fresh check stamp. The forced row must come first.
|
|
seedForCheck(t, s, "asura:popular", "https://asurascans.com/comics/popular", 100)
|
|
seedSecondReader(t, s, "asura:popular:2", "asura", "popular", 1001)
|
|
seedForCheck(t, s, "asura:forced", "https://asurascans.com/comics/forced", 900)
|
|
if err := s.ForceSeriesPoll("asura", "forced", 5000); err != nil {
|
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
|
}
|
|
|
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
|
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:forced" || due[1].Key() != "asura:popular" {
|
|
t.Fatalf("due order = %q, %q; want forced first, then popular", due[0].Key(), due[1].Key())
|
|
}
|
|
}
|
|
|
|
// A forced Series with no series URL is still not fetched — nothing to fetch —
|
|
// and one with no Bookmarks is still excluded by the join. The force flag
|
|
// opens the three gates it is allowed to, not the whole query.
|
|
func TestDueForLatestCheckForcedDoesNotOverrideURLOrJoin(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:nourl", "", 0)
|
|
if err := s.ForceSeriesPoll("asura", "nourl", 5000); err != nil {
|
|
t.Fatalf("ForceSeriesPoll: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(`
|
|
INSERT INTO series (site, series_id, title, series_url, cover, kind,
|
|
latest_chapter, latest_chapter_num, latest_checked_at,
|
|
force_poll_at)
|
|
VALUES ('asura', 'orphan', 'Orphan', 'https://asurascans.com/comics/orphan',
|
|
'', 'manga', '', NULL, 0, 5000)`); err != nil {
|
|
t.Fatalf("seed orphan: %v", err)
|
|
}
|
|
|
|
due, err := s.DueForLatestCheck("asura", 1000, noCeiling)
|
|
if err != nil {
|
|
t.Fatalf("DueForLatestCheck: %v", err)
|
|
}
|
|
if len(due) != 0 {
|
|
t.Fatalf("due = %v, want neither the URL-less nor the orphan series", due)
|
|
}
|
|
}
|
|
|
|
// A Correction writes the number, the derived label and the stamp, clears the
|
|
// raising Reader, and never touches either Sighting counter or the check
|
|
// stamp — a Correction is not a check and never judges a Reader (#149).
|
|
func TestCorrectLatestChapterStampsClearsAndDoesNotTouchCheckOrMarks(t *testing.T) {
|
|
s := newTestStore(t)
|
|
other := secondReader(t, s)
|
|
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 4321_000)
|
|
// A Reader raised the number, and carries a mark for it.
|
|
if err := s.RecordSighting(other, "asura", "solo", num2(3), 1000); err != nil {
|
|
t.Fatalf("RecordSighting: %v", err)
|
|
}
|
|
if _, err := s.db.Exec(`
|
|
UPDATE readers SET sighting_agreements = 5, sighting_disagreements = 2
|
|
WHERE id = $1`, other); err != nil {
|
|
t.Fatalf("mark reader: %v", err)
|
|
}
|
|
|
|
if err := s.CorrectLatestChapter("asura", "solo", 12.5, 9000); err != nil {
|
|
t.Fatalf("CorrectLatestChapter: %v", err)
|
|
}
|
|
|
|
var chapter string
|
|
var num float64
|
|
var stamp, checkedAt int64
|
|
var raisedBy any
|
|
if err := s.db.QueryRow(`
|
|
SELECT latest_chapter, latest_chapter_num, latest_corrected_at,
|
|
latest_checked_at, latest_raised_by
|
|
FROM series WHERE site = 'asura' AND series_id = 'solo'`).
|
|
Scan(&chapter, &num, &stamp, &checkedAt, &raisedBy); err != nil {
|
|
t.Fatalf("read back: %v", err)
|
|
}
|
|
if chapter != "Chapter 12.5" {
|
|
t.Errorf("latest_chapter = %q, want the derived label %q", chapter, "Chapter 12.5")
|
|
}
|
|
if num != 12.5 {
|
|
t.Errorf("latest_chapter_num = %v, want 12.5", num)
|
|
}
|
|
if stamp != 9000 {
|
|
t.Errorf("latest_corrected_at = %d, want 9000", stamp)
|
|
}
|
|
if checkedAt != 4321_000 {
|
|
t.Errorf("latest_checked_at = %d, want the untouched 4321000", checkedAt)
|
|
}
|
|
if raisedBy != nil {
|
|
t.Errorf("latest_raised_by = %v, want the attribution cleared", raisedBy)
|
|
}
|
|
|
|
readers, err := s.Readers()
|
|
if err != nil {
|
|
t.Fatalf("Readers: %v", err)
|
|
}
|
|
for _, r := range readers {
|
|
if r.ID == other && (r.Agreements != 5 || r.Disagreements != 2) {
|
|
t.Errorf("raising reader's marks = %+v, want agreements 5, disagreements 2 unchanged", r)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The stamp follows the number (spec #135): an Upsert resending the corrected
|
|
// value — a Reader's cached row after a correction — keeps it, and an Upsert
|
|
// that actually moves the number kills it. Unconditional zeroing would erase
|
|
// the fact while the value is still the owner's; that is the whole point of
|
|
// the clause.
|
|
func TestUpsertCorrectionStampFollowsTheNumber(t *testing.T) {
|
|
s := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo", Kind: KindManga,
|
|
SeriesURL: "https://asurascans.com/comics/solo", UpdatedAt: 1000,
|
|
}
|
|
if _, err := s.Upsert(s.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
|
t.Fatalf("CorrectLatestChapter: %v", err)
|
|
}
|
|
|
|
// Same number back: the value is still the owner's.
|
|
same := base
|
|
same.LatestChapterNum = num2(5)
|
|
if _, err := s.Upsert(s.OwnerID(), same); err != nil {
|
|
t.Fatalf("same-number upsert: %v", err)
|
|
}
|
|
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 9000 {
|
|
t.Fatalf("stamp after same-number Upsert = %d, want 9000 kept", got)
|
|
}
|
|
|
|
// A different number: a machine (or a Reader) wrote the value.
|
|
moved := base
|
|
moved.LatestChapterNum = num2(7)
|
|
if _, err := s.Upsert(s.OwnerID(), moved); err != nil {
|
|
t.Fatalf("moved upsert: %v", err)
|
|
}
|
|
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
|
t.Fatalf("stamp after moved Upsert = %d, want zeroed", got)
|
|
}
|
|
}
|
|
|
|
// The poller's chapter setter zeroes the stamp unconditionally: checkOne only
|
|
// calls it when the number differs, so the condition lives upstream and a
|
|
// second copy here would drift (#149).
|
|
func TestSetLatestChapterZeroesCorrectionStamp(t *testing.T) {
|
|
s := newTestStore(t)
|
|
seedForCheck(t, s, "asura:solo", "https://asurascans.com/comics/solo", 0)
|
|
if err := s.CorrectLatestChapter("asura", "solo", 5, 9000); err != nil {
|
|
t.Fatalf("CorrectLatestChapter: %v", err)
|
|
}
|
|
if err := s.SetLatestChapter("asura", "solo", "Chapter 6", 6); err != nil {
|
|
t.Fatalf("SetLatestChapter: %v", err)
|
|
}
|
|
if got := s.latestCorrectedAt(t, "asura", "solo"); got != 0 {
|
|
t.Fatalf("stamp after a machine write = %d, want zeroed", got)
|
|
}
|
|
}
|
|
|
|
// latestCorrectedAt reads the stamp column for the assertion above.
|
|
func (s *Store) latestCorrectedAt(t *testing.T, site, seriesID string) int64 {
|
|
t.Helper()
|
|
var stamp int64
|
|
if err := s.db.QueryRow(
|
|
`SELECT latest_corrected_at FROM series WHERE site = $1 AND series_id = $2`,
|
|
site, seriesID).Scan(&stamp); err != nil {
|
|
t.Fatalf("read stamp: %v", err)
|
|
}
|
|
return stamp
|
|
}
|
|
|
|
// num2 boxes a chapter number for the Bookmark fields that take a pointer.
|
|
func num2(f float64) *float64 { return &f }
|
|
// --- Cover addressing (ADR-0014): the address is the bytes' SHA-256 ---
|
|
|
|
// The address is what makes a re-art visible at all, so the same bytes must
|
|
// always name the same address and different bytes different ones — and the
|
|
// address must keep the 64-hex-digit shape CoverByAddress's guard still checks
|
|
// before any request-supplied value becomes a filesystem path.
|
|
func TestCoverAddressForBytesIsDeterministicAndDistinct(t *testing.T) {
|
|
first := CoverAddressForBytes([]byte("art"))
|
|
again := CoverAddressForBytes([]byte("art"))
|
|
other := CoverAddressForBytes([]byte("artwork"))
|
|
if first != again {
|
|
t.Fatalf("same bytes gave %q then %q, want one address", first, again)
|
|
}
|
|
if first == other {
|
|
t.Fatalf("different bytes gave the same address %q", first)
|
|
}
|
|
if !coverAddressRe.MatchString(first) {
|
|
t.Fatalf("address %q is not the 64-hex-digit shape the serving guard checks", first)
|
|
}
|
|
}
|
|
|
|
// ReplaceSeriesCover is the forced-replacement installer: it moves a Cover
|
|
// whether or not one exists, writes the source URL alongside it, and reports
|
|
// the three outcomes the Forced Poll has to tell apart.
|
|
func TestReplaceSeriesCover(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)
|
|
}
|
|
|
|
// A blank Cover: previous is "", and the row points at the new bytes.
|
|
previous, current, err := store.ReplaceSeriesCover("asura", "solo",
|
|
"https://cdn.asurascans.com/covers/solo.webp", []byte("first-art"), "image/webp")
|
|
if err != nil {
|
|
t.Fatalf("ReplaceSeriesCover on a blank: %v", err)
|
|
}
|
|
if previous != "" {
|
|
t.Fatalf("previous on a blank = %q, want empty", previous)
|
|
}
|
|
if want := CoverAddressForBytes([]byte("first-art")); current != want {
|
|
t.Fatalf("current = %q, want %q", current, want)
|
|
}
|
|
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
|
|
sr.Cover != "https://cdn.asurascans.com/covers/solo.webp" {
|
|
t.Fatalf("series after blank fill = %+v, want the new address and source URL", sr)
|
|
}
|
|
|
|
// A re-art: previous is the stranded address, current the new one.
|
|
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
|
|
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
|
|
if err != nil {
|
|
t.Fatalf("ReplaceSeriesCover over a filled Cover: %v", err)
|
|
}
|
|
if want := CoverAddressForBytes([]byte("first-art")); previous != want {
|
|
t.Fatalf("previous = %q, want the replaced address %q", previous, want)
|
|
}
|
|
if want := CoverAddressForBytes([]byte("second-art")); current != want {
|
|
t.Fatalf("current = %q, want %q", current, want)
|
|
}
|
|
if sr := readSeries(t, store, "asura", "solo"); sr.CoverAddress != current ||
|
|
sr.Cover != "https://cdn.asurascans.com/covers/solo-rebrand.webp" {
|
|
t.Fatalf("series after replacement = %+v, want the new address and source URL", sr)
|
|
}
|
|
// The replaced bytes stay served under their old address; nothing reclaims
|
|
// them in this ticket (the forced-poll wave does).
|
|
if _, _, ok, err := store.CoverByAddress(CoverAddressForBytes([]byte("first-art"))); err != nil || !ok {
|
|
t.Fatalf("superseded bytes = found %v, err %v, want still served", ok, err)
|
|
}
|
|
|
|
// The Site is serving the same artwork again: previous == current is the
|
|
// honest no-op the caller reports as "unchanged".
|
|
previous, current, err = store.ReplaceSeriesCover("asura", "solo",
|
|
"https://cdn.asurascans.com/covers/solo-rebrand.webp", []byte("second-art"), "image/jpeg")
|
|
if err != nil {
|
|
t.Fatalf("ReplaceSeriesCover over identical bytes: %v", err)
|
|
}
|
|
if previous != current {
|
|
t.Fatalf("identical bytes: previous = %q, current = %q, want one address", previous, current)
|
|
}
|
|
if want := CoverAddressForBytes([]byte("second-art")); current != want {
|
|
t.Fatalf("current = %q, want %q", current, want)
|
|
}
|
|
}
|
|
|
|
// Rows written before byte addressing hold the hash of their source URL and
|
|
// are never rehashed: GetCover — the poller's heal path — keeps resolving
|
|
// them through coverSourceAddress.
|
|
func TestGetCoverResolvesLegacyURLDerivedAddress(t *testing.T) {
|
|
store := newTestStore(t)
|
|
source := "https://cdn.example/legacy.jpg"
|
|
legacy := coverSourceAddress(source)
|
|
relativePath := coverRelativePath(legacy)
|
|
coverPath := filepath.Join(store.coverDir, filepath.FromSlash(relativePath))
|
|
if err := os.MkdirAll(filepath.Dir(coverPath), 0o755); err != nil {
|
|
t.Fatalf("create shard dir: %v", err)
|
|
}
|
|
if err := os.WriteFile(coverPath, []byte("legacy-bytes"), 0o644); err != nil {
|
|
t.Fatalf("write legacy file: %v", err)
|
|
}
|
|
if _, err := store.db.Exec(
|
|
`INSERT INTO covers (address, path, content_type) VALUES ($1, $2, $3)`,
|
|
legacy, relativePath, "image/jpeg"); err != nil {
|
|
t.Fatalf("plant legacy row: %v", err)
|
|
}
|
|
|
|
body, contentType, ok, err := store.GetCover(source)
|
|
if err != nil || !ok {
|
|
t.Fatalf("GetCover on a legacy row = %v, %v, want found", ok, err)
|
|
}
|
|
if string(body) != "legacy-bytes" || contentType != "image/jpeg" {
|
|
t.Fatalf("legacy cover = (%q, %q), want the planted bytes", body, contentType)
|
|
}
|
|
}
|
|
|
|
// SetSeriesURL is the one write that lifts the write-once rule of
|
|
// Series.SeriesURL (issue #151): a client PUT naming an existing Series still
|
|
// has its new URL dropped, yet the owner's repair lands where the Upsert
|
|
// would have ignored it.
|
|
func TestSetSeriesURLWritesWhereUpsertIgnores(t *testing.T) {
|
|
store := newTestStore(t)
|
|
base := Bookmark{
|
|
Key: "asura:solo", Site: "asura", SeriesID: "solo",
|
|
Title: "Solo Leveling", SeriesURL: "https://asurascans.com/comics/solo",
|
|
UpdatedAt: 1000,
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
|
|
// A client PUT naming the existing Series is refused: the row is shared,
|
|
// so the stored URL stands.
|
|
base.SeriesURL = "https://evil.example/solo"
|
|
if got, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
} else if got.SeriesURL != "https://asurascans.com/comics/solo" {
|
|
t.Fatalf("Upsert stored %q, want the original URL untouched", got.SeriesURL)
|
|
}
|
|
|
|
// The owner's repair writes where the Upsert would have ignored it.
|
|
repair := "https://asurascans.com/comics/solo-renumbered"
|
|
if err := store.SetSeriesURL("asura", "solo", repair); err != nil {
|
|
t.Fatalf("SetSeriesURL: %v", err)
|
|
}
|
|
sr := readSeries(t, store, "asura", "solo")
|
|
if sr.SeriesURL != repair {
|
|
t.Fatalf("stored URL = %q, want %q", sr.SeriesURL, repair)
|
|
}
|
|
}
|