741b23322b
Fixes five reported symptoms across comix.to and kagane.to. Diagnosing them turned up two latent bugs underneath, both of which had to be fixed for the kagane cover work to function at all.
## Reported symptoms and their causes
| # | Symptom | Cause |
|---|---------|-------|
| 1 | comix bookmark titled `Comix - Read Comics online for free` | comix is an SPA that rewrites `document.title` on client routing but never touches the server-rendered `og:title`. The adapter read `og:title`, so a cold load stored the homepage's title. |
| 2 | next comix bookmark gets the *previous* series' title | Same cause. After an in-page hop, `og:title` still holds whatever page loaded first. |
| 3 | comix cover shows the placeholder | comix serves no `og:image` at all, so `coverFromPage()` had nothing to read. |
| 4 | kagane chapter never appears in the bookmark list | Reader URLs carry no chapter number, so it is parsed out of `og:title`. Volume-numbered series render `"<Series> - Volume <v> Chapter <n>"`, which the suffix regex did not match, so `chapterNum` came back null and nothing was recorded. |
| 5 | kagane title includes the chapter, e.g. `SP Baby - Volume 1 Chapter 1` | Same unmatched regex — the tail was never stripped. One fix covers 4 and 5. |
| 6 | kagane cover blocked in the web UI | kagane serves covers behind its Cloudflare challenge **and** with `cross-origin-resource-policy: same-origin`. No `<img>` on the UI's origin can load one even from a browser holding the clearance cookie. Hot-linking cannot be made to work. |
## What changed
**Userscript.** comix titles now come from `document.title` with the chapter page's `" - Ch.<n>"` tail stripped, and the cover is the `img` whose `alt` matches the cleaned title. comix fills `document.title` a beat *after* the URL changes — later than the nav watcher's 300 ms snapshot — so the watcher also re-detects when the `detect()` signature changes, not only when the URL does. The kagane suffix regex takes an optional `Volume <v> ` segment. All three page shapes were captured live on 2026-08-08 and pinned as regression tests.
**Cover proxy.** `Bookmark.CoverURL()` rewrites a stored kagane `og:image` to `/img/kagane/{id}`; templates render `.CoverURL` instead of `.Cover`. The endpoint is session-gated like every other UI route and fetches through the shared headless browser, which is same-origin with kagane and so satisfies both the challenge and the CORP header. Results are memoised in-process, so a cover costs one navigation per deployment lifetime. With `BROWSER_WS_URL` unset the endpoint answers 404 rather than reaching for a nil fetcher — the same degrade-to-userscript behaviour the poller already has.
The image id is matched against a UUID regex before it reaches the browser. That gate is load-bearing rather than tidiness: the cover is a stored client-supplied string, so an unvalidated one turns this endpoint into an SSRF primitive aimed at the deployment's own network. `ServeMux` path-cleans a traversal into a redirect before the handler runs, but the handler does not depend on that, and a test pins it.
## Two latent bugs found underneath
**`BrowserFetcher.run` never let a challenge solve.** It navigated, waited for `body`, read once, and closed the tab — roughly half a second end to end. The Cloudflare interstitial has a `body` too, so `WaitReady` was satisfied by the challenge page itself. This made the challenge *unclearable* rather than merely slow: an interstitial needs several seconds of a live page to solve itself and write clearance into the browser's shared cookie jar, so tearing the tab down first means every subsequent call is challenged exactly like the one before it. `run` now holds one tab and re-reads until the caller's predicate reports an answer, bounded by `challengeTimeout` and the caller's own deadline. Exhausting the budget maps back to the 403 the poller already expects, keeping a challenged site distinct from a broken transport.
**`chromedp/headless-shell` cannot clear kagane's challenge at all.** It is a stripped Chrome build and the tells are structural rather than a header: `navigator.webdriver` is true, the plugin list is empty, and the client hints are Chromium- rather than Chrome-branded. Overriding `webdriver` through CDP was tried on its own and changed nothing.
All measured 2026-08-08 from one IP against the same cover, so the comparisons are like for like:
| Browser | Result |
|---------|--------|
| `chromedp/headless-shell:stable` | never cleared (90 s) |
| `zenika/alpine-chrome` | never cleared — ships Chrome 124, old enough that Cloudflare refuses it and old enough to break chromedp's CDP structs |
| `google-chrome`, default UA | never cleared (60 s) — `--headless=new` advertises `HeadlessChrome` |
| `google-chrome`, stock UA, `TZ=UTC` | never cleared (90 s) |
| `google-chrome`, stock UA, any non-UTC `TZ` | **cleared in ~4 s** |
Both remaining tells are load-bearing, and each was tested in isolation. `chrome/` is a Debian image with `google-chrome-stable`, a UA whose version is read back out of the binary at startup (a hardcoded one would drift out of step with the `Sec-CH-UA` hints on the next Chrome update and become a fresh tell), and no `--enable-automation`.
### The timezone tell: UTC, not a country mismatch
The first pass concluded the zone had to match the egress IP's country. Re-measuring against the actual deployment case shows that was wrong, and the correction is in `1552dd1`.
The original inference read the host's `/etc/timezone` (`Asia/Bangkok`) and assumed a Thai egress. It isn't — this host egresses from an Indonesian IP. `Asia/Bangkok` cleared not because it matched a country but because it simply isn't UTC, and the two share +07, which hid the distinction. Same container, same Indonesian IP:
| `TZ` | Result |
|------|--------|
| `UTC` | never cleared (60 s, **twice**) |
| `Asia/Jakarta` | cleared in 4 s |
| `America/New_York` | cleared in 4 s |
`America/New_York` matches neither the country nor the offset nor the hemisphere and clears just as fast. A UTC clock is itself the bot signal — Cloudflare scores it as the datacenter default — and any real zone satisfies the check. `BROWSER_TZ` therefore needs a plausible zone, not a geolocated one, and a deployment that changes region need not keep it in sync.
One sharp edge remains: the usual `-v /etc/localtime:/etc/localtime:ro` does **not** work. Chrome resolves the zone through ICU, which takes the name from that path's symlink target and ignores the file's contents, so glibc reports the host zone while Chrome still reports UTC. `/etc/timezone` carries the name and is mounted instead.
Chrome also binds its DevTools port to loopback and silently ignores `--remote-debugging-address`, which is why headless-shell fronted it with socat. This image does the same, so it stays a drop-in: the compose service keeps the `headless-shell` name and its pinned address, and `BROWSER_WS_URL` is unchanged.
## Verification
```
go test ./... all packages ok
node --test 37 + 12 pass, 0 fail
SMOKE_BROWSER_WS_URL=... go test -run TestSmokeKagane ./internal/latest
TestSmokeKaganeImage PASS (5.29s) fetched 56710 bytes of image/webp
TestSmokeKaganeGet PASS (1.17s) status=200, real chapter-list JSON
```
The smoke test ran against the exact compose configuration — built image, empty `BROWSER_TZ`, `/etc/timezone` mounted, cold profile — hitting real kagane.to. It skips unless `SMOKE_BROWSER_WS_URL` names a sidecar, so `go test ./...` stays hermetic and Docker-only.
A red smoke run means the challenge is not clearing from that IP, which is a live, time-varying fact to re-check rather than necessarily a defect.
## Security invariants
- Auth unchanged. `/img/kagane/{id}` is session-gated by `requireSession`, the same guard as every other UI route.
- Outbound fetch gated: the id is UUID-validated before it reaches the browser, keeping the existing rule that a client-supplied string never selects a fetch target unchecked.
- No new secrets, no new logging of credentials, no change to CORS, sessions, or crypto.
- Templates still escape everything; `.CoverURL` returns a plain string and is not wrapped in `template.HTML`/`URL`.
- One new dependency-free image (`chrome/`) built from Debian plus Google's own apt repo; no new Go modules.
## Deploying
Needs `docker compose build headless-shell`.
**A UTC host must set `BROWSER_TZ`, or kagane silently stops working.** With it unset the sidecar falls back to the host's `/etc/timezone`; on a UTC server that yields UTC, which is the one value that never clears. Any real zone works — `BROWSER_TZ=Asia/Jakarta` for the current deployment. `.env.example` now documents this; it previously did not mention the knob at all.
Only the browser sidecar reads `BROWSER_TZ`. The backend keeps its UTC clock, and stored timestamps are unix ms, so nothing else shifts.
## Deliberately not done
Retry/backoff around the cover proxy, and a panel-side cover fix. The panel renders no covers, and covers cache in-process after the first fetch. Worth adding if kagane starts rate-limiting.
## Correction after review of the deployment case
`1552dd1` was added after the branch was first pushed: the deployment host runs UTC with an Indonesian egress IP, which prompted re-measuring the timezone claim and falsifying it. The earlier commits' reasoning is left intact rather than rebased away, so the diagnostic trail — including the wrong turn and what disproved it — stays readable.
Reviewed-on: #37
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
1234 lines
42 KiB
Go
1234 lines
42 KiB
Go
package store
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"os"
|
|
"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"))}
|
|
|
|
func newTestStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
store, err := Open(pgtest.URL(t), testOwner)
|
|
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)
|
|
first, err := Open(url, testOwner)
|
|
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)
|
|
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)
|
|
store, err := Open(url, testOwner)
|
|
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)
|
|
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, 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, 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(), 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 TestCoverURL(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cover string
|
|
want string
|
|
}{
|
|
{
|
|
"kagane routes through the proxy",
|
|
"https://kagane.to/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
|
|
"/img/kagane/019fe11a-84c3-7fc3-a84b-88787374b617",
|
|
},
|
|
{
|
|
"another site is served as stored",
|
|
"https://gg.asuracomic.net/storage/media/1/conversions/cover.webp",
|
|
"https://gg.asuracomic.net/storage/media/1/conversions/cover.webp",
|
|
},
|
|
{
|
|
"a lookalike host is not rewritten",
|
|
"https://evil.example/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
|
|
"https://evil.example/api/v2/image/019fe11a-84c3-7fc3-a84b-88787374b617/compressed",
|
|
},
|
|
{"no cover stays empty", "", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := (Bookmark{Cover: tc.cover}).CoverURL(); got != tc.want {
|
|
t.Errorf("CoverURL() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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, cover
|
|
// and URL — there is no other source for them (ADR-0003).
|
|
func TestUpsertCreatesSeriesFromClient(t *testing.T) {
|
|
store := newTestStore(t)
|
|
if _, 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,
|
|
}); err != nil {
|
|
t.Fatalf("Upsert: %v", err)
|
|
}
|
|
|
|
sr := readSeries(t, store, "asura", "solo")
|
|
if sr.Title != "Solo Leveling" || sr.SeriesURL != "https://asurascans.com/comics/solo" ||
|
|
sr.Cover != "https://asurascans.com/covers/solo.jpg" {
|
|
t.Fatalf("series = %+v, want client title/url/cover stored", sr)
|
|
}
|
|
}
|
|
|
|
// A PUT naming an existing series must not overwrite its title, cover or URL:
|
|
// the row is shared, and those values are scraped page content (ADR-0003).
|
|
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",
|
|
Cover: "https://asurascans.com/covers/solo.jpg", LastChapterNum: 10,
|
|
UpdatedAt: 1000,
|
|
}
|
|
if _, err := store.Upsert(store.OwnerID(), base); err != nil {
|
|
t.Fatalf("seed: %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)
|
|
}
|
|
if got.Title != "Solo Leveling" || got.SeriesURL != "https://asurascans.com/comics/solo" ||
|
|
got.Cover != "https://asurascans.com/covers/solo.jpg" {
|
|
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 waiting for a poll.
|
|
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", Cover: "https://asurascans.com/covers/solo.jpg",
|
|
UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %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)
|
|
}
|
|
if stored.Title != "Solo Leveling" || stored.Cover != "https://asurascans.com/covers/solo.jpg" {
|
|
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, 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, 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)
|
|
first, err := Open(url, Owner{DiscordID: "owner", TokenHash: sha256.Sum256([]byte("hash-v1"))})
|
|
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"))})
|
|
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)
|
|
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(), 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(), 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)
|
|
}
|
|
}
|