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( `Chapter %v`, 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) } }