ba679223b2
Closes #103. A userscript PUT already carries the Latest Chapter the Reader's own browser read off the Series page. It may now stand in for a Poll, under one restriction and one ceiling: - **Solitary Series only** — a Series two Readers share is Polled on schedule however recently it was sighted, so one Reader's mistake can never reach another's list. - **One rest of standing**, and a **six-rest ceiling** (`sightingCeilingRests`, counted in the Site's own Rest): however many Sightings arrive, an unpolled Series is Polled. Both live in the due query's HAVING clause (`Store.DueForLatestCheck`) — the same place the schedule has always been decided, so no timer and no second code path can disagree with it. No new query per scheduler round. Judgement costs no extra request. `Poller.checkOne` already compares what the Site publishes against what is stored: a lower number contradicts the Sighting (Reader and both numbers logged), the same number confirms it, a higher number is the Site publishing and clears the attribution instead. Three contradictions stop that Reader deferring — their reports still write the Latest Chapter — and twenty consecutive confirmations forgive them, as does the owner's clear-marks control from #102. One client change was required: both userscripts skipped the PUT when the number had not moved, so the case the whole mechanism exists for — visiting a Series with nothing new — never reached the backend. `reportLatestChapter` sends it, skipping only the local write and the re-render. A numberless PUT (favourite toggle, progress from a chapter page) is no Sighting and defers nothing. Schema: migration `0011_series_sightings.sql` adds `series.latest_sighted_at` and `series.latest_raised_by`. Trust model, thresholds, and rejected alternatives with their citations: `docs/adr/0011-sighting-deferral-trust-model.md`. Reviewed on both axes (spec against #103, standards against the repo's rules); the blocker — attribution surviving a Poll that overtook the report — is fixed and has a test that fails without the fix. Verification: `go test ./...` green (needs Docker), `node --test userscript/test/*.test.js` 66 pass. Reviewed-on: #108 Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com> Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
495 lines
19 KiB
Go
495 lines
19 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, sightingCeilingRests*defaultRest)
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
// A Poll that overtakes a Sighting takes ownership of the row: the value stored
|
|
// afterwards is the Poll's own, so a later retraction is not the Reader's fault
|
|
// and must not be charged to them.
|
|
func TestAttributionDoesNotSurviveAPollThatOvertookIt(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
|
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)
|
|
at := now.Add(defaultRest + time.Minute)
|
|
p.Now = func() time.Time { return at }
|
|
p.runOnce(context.Background())
|
|
if got := readLatestNum(t, s, s.OwnerID(), demonicKey); got != 296 {
|
|
t.Fatalf("latest after the Poll = %v, want the Site's own 296", got)
|
|
}
|
|
|
|
var logs strings.Builder
|
|
prev := log.Writer()
|
|
log.SetOutput(&logs)
|
|
t.Cleanup(func() { log.SetOutput(prev) })
|
|
|
|
f.body = demonicFixture(290) // the Site retracts what only the Poll wrote
|
|
p.Now = func() time.Time { return at.Add(defaultRest + time.Minute) }
|
|
p.runOnce(context.Background())
|
|
if strings.Contains(logs.String(), "sighting contradicted") {
|
|
t.Fatalf("a retraction of the Poll's own value was charged to a Reader: %s", logs.String())
|
|
}
|
|
}
|
|
|
|
// A PUT with no Latest Chapter in it — a favourite toggle, progress written
|
|
// from a chapter page — is nobody looking at the Series page, so it buys no
|
|
// deferral. Otherwise a client could suppress a Series' Polls while reporting
|
|
// nothing, and with nothing reported there would be nothing to judge.
|
|
func TestPutWithoutALatestChapterDoesNotDefer(t *testing.T) {
|
|
s, _ := newTestStore(t)
|
|
now := time.UnixMilli(20 * time.Hour.Milliseconds())
|
|
seedDemonic(t, s, now.Add(-2*time.Hour).UnixMilli())
|
|
// The handler's own call, with the field the client omitted.
|
|
if err := s.RecordSighting(s.OwnerID(), "demonic", "Catastrophic-Necromancer",
|
|
nil, now.Add(-time.Minute).UnixMilli()); err != nil {
|
|
t.Fatalf("RecordSighting: %v", err)
|
|
}
|
|
|
|
f := &fakeFetcher{body: demonicFixture(296), status: 200}
|
|
p := newTestPoller(t, s, f, now)
|
|
p.runOnce(context.Background())
|
|
if got := f.callCount(); got != 1 {
|
|
t.Fatalf("fetched %d times after a PUT carrying no chapter, want 1", got)
|
|
}
|
|
}
|