56afb9f237
A userscript PUT already carries the Latest Chapter the Reader's own browser read; it now stands in for a Poll where being wrong can hurt nobody else. Deferral lives in the due query beside the rest cutoff: one Bookmark, sighted within one rest, under the six-rest ceiling. checkOne judges the Reader whose report raised the value off the comparison it already makes - three contradictions stop them deferring, twenty confirmations forgive. ADR-0011 records the trust model and the rejected alternatives.
442 lines
17 KiB
Go
442 lines
17 KiB
Go
package latest
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"log"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
// Sightings (issue #103) are specified at the Poller seam, with the store as
|
|
// the way in: a Sighting is seeded the way handlers.Put performs one, a round
|
|
// is run against the injected fetcher and a frozen clock, and the assertions
|
|
// are the two observable facts — whether the Series was fetched, and what the
|
|
// stored Latest Chapter is afterwards. Nothing here asserts counter arithmetic
|
|
// through an internal call or reads how a deferral is represented in a row.
|
|
|
|
const (
|
|
sightingSlug = "chronicles-of-the-demon-faction-f886a8af"
|
|
sightingKey = "asura:" + sightingSlug
|
|
sightingURL = "https://asurascans.com/comics/" + sightingSlug
|
|
)
|
|
|
|
// sightingFixtureLatest is the newest chapter asuraSeriesFixture publishes.
|
|
const sightingFixtureLatest = 181.0
|
|
|
|
// sight performs one Sighting exactly as the JSON API does (handlers.Put):
|
|
// RecordSighting against the row as stored, then the Upsert that stores the
|
|
// reported value. The order is load-bearing — the raise comparison has nothing
|
|
// to compare against once the Upsert has landed — and the bookmark's own fields
|
|
// are carried over untouched, which is what a userscript PUT does when it
|
|
// echoes back the row it cached.
|
|
func sight(t *testing.T, s *store.Store, readerID int64, key string, num float64, at time.Time) {
|
|
t.Helper()
|
|
site, seriesID, ok := strings.Cut(key, ":")
|
|
if !ok {
|
|
t.Fatalf("key %q: no ':' separator", key)
|
|
}
|
|
b, found, err := s.Get(readerID, key)
|
|
if err != nil || !found {
|
|
t.Fatalf("sight %q: get: %v found=%v", key, err, found)
|
|
}
|
|
if err := s.RecordSighting(readerID, site, seriesID, &num, at.UnixMilli()); err != nil {
|
|
t.Fatalf("sight %q: %v", key, err)
|
|
}
|
|
b.LatestChapter = fmt.Sprintf("Chapter %v", num)
|
|
b.LatestChapterNum = &num
|
|
b.UpdatedAt = at.UnixMilli()
|
|
if _, err := s.Upsert(readerID, b); err != nil {
|
|
t.Fatalf("sight %q: upsert: %v", key, err)
|
|
}
|
|
}
|
|
|
|
// secondReader is another Reader on the same database. The owner seed is the
|
|
// only reader-creation path in this package, so a second Open as a different
|
|
// owner is how a test gets one (as TestRunOnceFetchesSharedSeriesOnce does).
|
|
func secondReader(t *testing.T, dbURL string) *store.Store {
|
|
t.Helper()
|
|
other, err := store.Open(dbURL,
|
|
store.Owner{DiscordID: "second-reader", TokenHash: sha256.Sum256([]byte("second-token-hash"))},
|
|
t.TempDir(), testCoverBaseURL)
|
|
if err != nil {
|
|
t.Fatalf("Open second reader: %v", err)
|
|
}
|
|
t.Cleanup(func() { other.Close() })
|
|
return other
|
|
}
|
|
|
|
func readLatestNum(t *testing.T, s *store.Store, readerID int64, key string) float64 {
|
|
t.Helper()
|
|
b, ok, err := s.Get(readerID, key)
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get %q: %v ok=%v", key, err, ok)
|
|
}
|
|
if b.LatestChapterNum == nil {
|
|
t.Fatalf("%q has no latest chapter", key)
|
|
}
|
|
return *b.LatestChapterNum
|
|
}
|
|
|
|
// A Series only one Reader bookmarks is the case where being wrong can hurt
|
|
// nobody but the Reader who reported it, so their Sighting stands in for the
|
|
// Poll and the round leaves the Series alone.
|
|
func TestSightingOnSolitarySeriesDefersPoll(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
|
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
|
|
|
if got := f.callCount(); got != 0 {
|
|
t.Fatalf("fetched %d times after a Sighting on a solitary Series, want 0", got)
|
|
}
|
|
}
|
|
|
|
// On a shared Series the Sighting still writes the Latest Chapter for everyone,
|
|
// but the Poll happens on schedule anyway — which is what corrects a wrong
|
|
// value within the hour instead of letting it persist.
|
|
func TestSightingOnSharedSeriesDoesNotDeferPoll(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
|
other := secondReader(t, dbURL)
|
|
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
|
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
|
|
}); err != nil {
|
|
t.Fatalf("seed second reader: %v", err)
|
|
}
|
|
sight(t, s, s.OwnerID(), sightingKey, 200, now.Add(-10*time.Minute))
|
|
|
|
// The Sighting updated the shared row immediately, before any Poll.
|
|
if got := readLatestNum(t, s, s.OwnerID(), sightingKey); got != 200 {
|
|
t.Fatalf("latest after the Sighting = %v, want 200", got)
|
|
}
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
|
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("fetched %d times after a Sighting on a shared Series, want 1", got)
|
|
}
|
|
if got := readLatestNum(t, s, s.OwnerID(), sightingKey); got != sightingFixtureLatest {
|
|
t.Fatalf("latest after the Poll = %v, want the Site's own %v", got, sightingFixtureLatest)
|
|
}
|
|
}
|
|
|
|
// Reporting a chapter is not reading one: a Sighting may move the Latest
|
|
// Chapter and nothing else. Both the solitary and the shared case, because the
|
|
// deferral branch must not be where this guarantee lives.
|
|
func TestSightingLeavesProgressAndOrderingUntouched(t *testing.T) {
|
|
for _, shared := range []bool{false, true} {
|
|
name := "solitary"
|
|
if shared {
|
|
name = "shared"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
read := 5.0
|
|
if _, err := s.Upsert(s.OwnerID(), store.Bookmark{
|
|
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, SeriesURL: sightingURL,
|
|
LastChapter: "Chapter 5", LastChapterNum: read,
|
|
LastChapterURL: sightingURL + "/chapter/5", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if shared {
|
|
other := secondReader(t, dbURL)
|
|
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
|
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
|
|
}); err != nil {
|
|
t.Fatalf("seed second reader: %v", err)
|
|
}
|
|
}
|
|
|
|
sight(t, s, s.OwnerID(), sightingKey, 200, time.UnixMilli(9_000_000))
|
|
|
|
b, ok, err := s.Get(s.OwnerID(), sightingKey)
|
|
if err != nil || !ok {
|
|
t.Fatalf("Get: %v ok=%v", err, ok)
|
|
}
|
|
if b.LatestChapterNum == nil || *b.LatestChapterNum != 200 {
|
|
t.Fatalf("LatestChapterNum = %v, want 200", b.LatestChapterNum)
|
|
}
|
|
if b.LastChapterNum != read {
|
|
t.Fatalf("LastChapterNum = %v, want %v: a Sighting is not Progress", b.LastChapterNum, read)
|
|
}
|
|
if b.UpdatedAt != 1000 {
|
|
t.Fatalf("updated_at moved to %d: a Sighting must not reorder the list", b.UpdatedAt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The ceiling is what makes trusting a client report safe: however recently a
|
|
// Series was sighted, one that has not been Polled in six hours is Polled.
|
|
func TestSightingCeilingForcesPoll(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-7*time.Hour).UnixMilli())
|
|
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-time.Minute))
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
newTestPoller(t, s, f, now).runOnce(context.Background())
|
|
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("fetched %d times past the %s ceiling, want 1", got, sightingCeiling)
|
|
}
|
|
}
|
|
|
|
// Deferral is decided from live facts every round, so a Series that gains a
|
|
// second Bookmark stops deferring at once — and one that loses it defers again.
|
|
func TestDeferralFollowsTheBookmarkCount(t *testing.T) {
|
|
s, dbURL := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
|
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
p := newTestPoller(t, s, f, now)
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 0 {
|
|
t.Fatalf("solitary Series fetched %d times, want 0", got)
|
|
}
|
|
|
|
other := secondReader(t, dbURL)
|
|
if _, err := s.Upsert(other.OwnerID(), store.Bookmark{
|
|
Key: sightingKey, Site: "asura", SeriesID: sightingSlug, UpdatedAt: 2000,
|
|
}); err != nil {
|
|
t.Fatalf("seed second reader: %v", err)
|
|
}
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("shared Series fetched %d times, want 1", got)
|
|
}
|
|
|
|
// The Poll above consumed the rest, so move past it before asking again.
|
|
if err := other.Delete(other.OwnerID(), sightingKey); err != nil {
|
|
t.Fatalf("delete second bookmark: %v", err)
|
|
}
|
|
later := now.Add(2 * time.Hour)
|
|
p.Now = func() time.Time { return later }
|
|
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, later.Add(-time.Minute))
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("Series fetched %d times after returning to one Bookmark, want 1", got)
|
|
}
|
|
}
|
|
|
|
// A Series nobody reports any more returns to the normal schedule on its own:
|
|
// the Sighting's standing lasts one rest, not forever.
|
|
func TestDeferralExpiresWithoutFurtherSightings(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedForCheck(t, s, sightingKey, sightingURL, now.Add(-2*time.Hour).UnixMilli())
|
|
sight(t, s, s.OwnerID(), sightingKey, sightingFixtureLatest, now.Add(-10*time.Minute))
|
|
|
|
f := &fakeFetcher{body: asuraSeriesFixture, status: 200}
|
|
p := newTestPoller(t, s, f, now)
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 0 {
|
|
t.Fatalf("fetched %d times while the Sighting stood, want 0", got)
|
|
}
|
|
|
|
p.Now = func() time.Time { return now.Add(90 * time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("fetched %d times once the Sighting aged out, want 1", got)
|
|
}
|
|
}
|
|
|
|
// demonicFixture publishes one chapter in demonicscans' live page shape, so a
|
|
// test can make a Site publish an arbitrary number rather than the one the
|
|
// captured fixture froze.
|
|
func demonicFixture(num float64) string {
|
|
return fmt.Sprintf(
|
|
`<a href="/chaptered.php?manga=11799&chapter=%v" class="chplinks" title="Catastrophic Necromancer %v">Chapter %v</a>`,
|
|
num, num, num)
|
|
}
|
|
|
|
const (
|
|
demonicKey = "demonic:Catastrophic-Necromancer"
|
|
demonicURL = "https://demonicscans.org/manga/Catastrophic-Necromancer"
|
|
)
|
|
|
|
// contradictOnce reports a chapter that does not exist and then runs the round
|
|
// that catches it, returning when that round ran so a caller can chain the
|
|
// next one. The wait is one rest and a minute: a Sighting stands in for exactly
|
|
// one rest, so that is the first moment this solitary Series is Polled again.
|
|
func contradictOnce(t *testing.T, s *store.Store, p *Poller, sightAt time.Time, real float64) time.Time {
|
|
t.Helper()
|
|
sight(t, s, s.OwnerID(), demonicKey, real+500, sightAt)
|
|
at := sightAt.Add(defaultRest + time.Minute)
|
|
p.Now = func() time.Time { return at }
|
|
p.runOnce(context.Background())
|
|
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != real {
|
|
t.Fatalf("latest after the Poll = %v, want the Site's own %v", got, real)
|
|
}
|
|
return at
|
|
}
|
|
|
|
func seedDemonic(t *testing.T, s *store.Store, checkedAt int64) {
|
|
t.Helper()
|
|
seedForCheck(t, s, demonicKey, demonicURL, checkedAt)
|
|
}
|
|
|
|
// A Poll finding a lower number than stored means the Sighting that raised it
|
|
// was false. The Reader is named — not the Series flagged — and both numbers are
|
|
// logged, because that is what tells a broken adapter from a deliberate lie.
|
|
func TestPollContradictingASightingNamesTheReaderAndBothNumbers(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
|
|
|
var logs strings.Builder
|
|
prev := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
|
|
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
|
p := newTestPoller(t, s, f, now)
|
|
contradictOnce(t, s, p, now, 296)
|
|
|
|
got := logs.String()
|
|
for _, want := range []string{
|
|
fmt.Sprintf("reader %d", s.OwnerID()), "796", "296", demonicKey,
|
|
} {
|
|
if !strings.Contains(got, want) {
|
|
t.Fatalf("contradiction log = %q, want it to name %q", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Three contradictions cost the Reader the right to defer. Nothing here writes
|
|
// a counter: the marks are earned through Polls, which is the only way
|
|
// production produces them.
|
|
func TestThreeContradictionsStopDeferral(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
start := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
|
|
|
|
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
|
p := newTestPoller(t, s, f, start)
|
|
at := start
|
|
for range store.SightingDisagreementLimit {
|
|
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
|
|
}
|
|
fetchesSoFar := f.callCount()
|
|
|
|
// The marked Reader sights the same solitary Series again. It still writes
|
|
// the Latest Chapter — the penalty removes a privilege, it does not silence
|
|
// anyone — but the Poll is no longer postponed: the round below runs while a
|
|
// trusted Reader's Sighting would still be standing, and fetches anyway.
|
|
sight(t, s, s.OwnerID(), demonicKey, 900, at.Add(31*time.Minute))
|
|
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 900 {
|
|
t.Fatalf("latest after a marked Reader's Sighting = %v, want 900", got)
|
|
}
|
|
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != fetchesSoFar+1 {
|
|
t.Fatalf("marked Reader's Sighting still deferred the Poll (fetches %d, want %d)",
|
|
got, fetchesSoFar+1)
|
|
}
|
|
}
|
|
|
|
// The owner's remedy for a mark a broken Site adapter produced restores the
|
|
// privilege without a wait and without SQL.
|
|
func TestClearingMarksRestoresDeferral(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
start := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
|
|
|
|
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
|
p := newTestPoller(t, s, f, start)
|
|
at := start
|
|
for range store.SightingDisagreementLimit {
|
|
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
|
|
}
|
|
if err := s.ClearReaderMarks(s.OwnerID()); err != nil {
|
|
t.Fatalf("ClearReaderMarks: %v", err)
|
|
}
|
|
|
|
fetchesSoFar := f.callCount()
|
|
sight(t, s, s.OwnerID(), demonicKey, 900, at.Add(31*time.Minute))
|
|
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != fetchesSoFar {
|
|
t.Fatalf("fetched %d times after the marks were cleared, want %d: deferral must resume",
|
|
got, fetchesSoFar)
|
|
}
|
|
}
|
|
|
|
// Recovery is automatic but expensive: twenty Polls that each confirm a
|
|
// Sighting of this Reader's clear the marks. Each round needs a new chapter,
|
|
// because only a report that raises the stored number is attributed and so only
|
|
// that one can be confirmed.
|
|
func TestTwentyAgreementsClearTheMarks(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
start := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedDemonic(t, s, start.Add(-2*time.Hour).UnixMilli())
|
|
|
|
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
|
p := newTestPoller(t, s, f, start)
|
|
at := start
|
|
for range store.SightingDisagreementLimit {
|
|
at = contradictOnce(t, s, p, at.Add(time.Minute), 296)
|
|
}
|
|
|
|
chapter := 296.0
|
|
for range store.SightingAgreementsToClear {
|
|
chapter++
|
|
sight(t, s, s.OwnerID(), demonicKey, chapter, at.Add(time.Minute))
|
|
f.body = demonicFixture(chapter) // the Site publishes what was reported
|
|
at = at.Add(defaultRest + time.Minute)
|
|
p.Now = func() time.Time { return at }
|
|
p.runOnce(context.Background())
|
|
}
|
|
|
|
fetchesSoFar := f.callCount()
|
|
chapter++
|
|
sight(t, s, s.OwnerID(), demonicKey, chapter, at.Add(31*time.Minute))
|
|
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != fetchesSoFar {
|
|
t.Fatalf("fetched %d times after %d confirmations, want %d: the marks must be forgiven",
|
|
got, store.SightingAgreementsToClear, fetchesSoFar)
|
|
}
|
|
}
|
|
|
|
// A Poll finding a higher number is the Site publishing since the Sighting and
|
|
// means nothing about the Reader — no mark, and no credit either.
|
|
func TestPollFindingHigherNumberIsNotAContradiction(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
|
// Reported truthfully, then the Site published one more.
|
|
sight(t, s, s.OwnerID(), demonicKey, 295, now.Add(-10*time.Minute))
|
|
|
|
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
|
p := newTestPoller(t, s, f, now)
|
|
// One rest on, the Sighting has lapsed and the Poll happens.
|
|
p.Now = func() time.Time { return now.Add(7 * time.Hour) }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("fetched %d times past the ceiling, want 1", got)
|
|
}
|
|
|
|
// Unmarked, so a fresh Sighting still defers.
|
|
at := now.Add(9 * time.Hour)
|
|
sight(t, s, s.OwnerID(), demonicKey, 296, at.Add(-time.Minute))
|
|
p.Now = func() time.Time { return at }
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("a Reader whose report the Site overtook lost the right to defer (fetches %d, want 1)", got)
|
|
}
|
|
}
|