Artifact 03ac5d19578bd342a7ba765703e21c87847f6f561179ff960f746b576773a6c3:
- File
internal/oltp/read_test.go
— part of check-in
[c8a93fda64]
at
2026-08-25 22:10:51
on branch trunk
— Forum list: mute what you have read, not what is old
Dimming keyed on a 30-day window meant a quiet forum rendered almost entirely grey. Read-state replaces it: forum_read stores when an account last viewed a thread, and a thread is read only while its newest post is no newer than that view — so a reply re-brightens it with no flag to clear. Anonymous readers have no read-state and see every row at full ink.
Marking read is its own op, not a side effect of forum.get, so crawlers and API clients cannot spend a reader's unread state. Titles now take the accent colour on hover (gated on hover: hover) and on :active, so touch keeps the affordance. (user: dmestas, size: 4779) [annotate] [blame] [check-ins using]
package oltp import "testing" // The core claim: read is a COMPARISON, not a flag. Marking read is a // point in time, and a reply that lands after it makes the thread // unread again without anyone clearing anything. func TestForumReadIsRelativeToLastReply(t *testing.T) { db := testDB(t) threads := map[string]string{"t1": "2026-01-01T10:00:00"} read, err := db.ForumRead("r", "alice", threads) if err != nil { t.Fatal(err) } if read["t1"] { t.Fatal("a thread nobody has opened must not read as seen") } if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } if read, _ = db.ForumRead("r", "alice", threads); !read["t1"] { t.Fatal("after marking, the thread must read as seen") } // A new reply arrives. Nothing touched alice's row — the thread // goes unread because the comparison changed, which is the whole // point of storing a timestamp instead of a boolean. threads["t1"] = "2030-06-01T09:00:00" if read, _ = db.ForumRead("r", "alice", threads); read["t1"] { t.Fatal("a reply newer than the last view must make the thread unread again") } } // Read-state is per account, and anonymous callers have none — the // anonymous list is meant to render every row at full ink. func TestForumReadIsPerAccount(t *testing.T) { db := testDB(t) threads := map[string]string{"t1": "2026-01-01T10:00:00"} if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } if read, _ := db.ForumRead("r", "bob", threads); read["t1"] { t.Fatal("alice reading a thread must not mark it read for bob") } read, err := db.ForumRead("r", "", threads) if err != nil { t.Fatal(err) } if len(read) != 0 { t.Fatalf("anonymous read-state = %v, want empty", read) } // Repos are separate namespaces, same as every other oltp table. if read, _ := db.ForumRead("other", "alice", threads); read["t1"] { t.Fatal("read-state must not leak across repos") } } // Opening a thread with nothing new in it must not write. Thread-opens // are the most common act on a forum and SQLite has one writer, so a // re-visit staying a pure read is a property worth pinning. func TestForumReadRevisitDoesNotWrite(t *testing.T) { db := testDB(t) if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } var first string if err := db.db.QueryRow(`SELECT at FROM forum_read WHERE repo='r' AND account='alice' AND thread='t1'`).Scan(&first); err != nil { t.Fatal(err) } // Same thread, still nothing newer than the last view. if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00"); err != nil { t.Fatal(err) } var second string if err := db.db.QueryRow(`SELECT at FROM forum_read WHERE repo='r' AND account='alice' AND thread='t1'`).Scan(&second); err != nil { t.Fatal(err) } if second != first { t.Fatalf("re-visit rewrote the row: %q -> %q; the upsert guard should have skipped it", first, second) } } // The watermark sweeps everything at once and takes the rows it // subsumes with it, so the table does not grow as accounts x threads. func TestMarkAllForumReadSweepsAndPrunes(t *testing.T) { db := testDB(t) threads := map[string]string{ "t1": "2026-01-01T10:00:00", "t2": "2026-02-01T10:00:00", "t3": "2026-03-01T10:00:00", } if err := db.MarkForumRead("r", "alice", "t1", threads["t1"]); err != nil { t.Fatal(err) } if err := db.MarkAllForumRead("r", "alice"); err != nil { t.Fatal(err) } read, err := db.ForumRead("r", "alice", threads) if err != nil { t.Fatal(err) } for root := range threads { if !read[root] { t.Fatalf("%s must read as seen after mark-all-read", root) } } var rows int if err := db.db.QueryRow(`SELECT count(*) FROM forum_read WHERE repo='r' AND account='alice'`).Scan(&rows); err != nil { t.Fatal(err) } if rows != 0 { t.Fatalf("per-thread rows below the watermark = %d, want 0 (pruned)", rows) } // The sweep is a point in time, not a permanent silence: a reply // after it still surfaces. threads["t2"] = "2030-06-01T09:00:00" if read, _ = db.ForumRead("r", "alice", threads); read["t2"] { t.Fatal("a reply after the watermark must make the thread unread again") } } // Stored marks and index dates are compared as strings, so a stored // RFC3339-shaped value must not read as newer than an equal index date // just because it carries a zone suffix. func TestForumReadToleratesLongerTimestamps(t *testing.T) { db := testDB(t) if err := db.MarkForumRead("r", "alice", "t1", "2026-01-01T10:00:00Z"); err != nil { t.Fatal(err) } read, _ := db.ForumRead("r", "alice", map[string]string{"t1": "2026-01-01T10:00:00Z"}) if !read["t1"] { t.Fatal("a zone-suffixed date must compare equal to the stored 19-char mark") } }