Skip to content
quarry Artifact [5a03e77e93]
Login

Artifact [5a03e77e93]

Artifact 5a03e77e9336b36fb943033e51047ba3d2d6913e6dee9cdbfa81f159f1df00bb:


package oltp

import (
	"testing"
	"time"
)

// 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")
	}
}

// Marks are stored 19-char and index dates may carry a zone suffix, so
// the two are compared as strings only after both are clipped. Without
// that, a trailing Z would make an OLD post sort after the mark and the
// thread would never go quiet.
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 not read as newer than the mark that follows it")
	}
}

// Opening a thread in the same second its newest post landed must mute
// it: that post was in the page the reader was handed. The mark's
// fractional part is the only thing that makes the two orderable, so
// this is really a test that the widened stamp survives a round trip.
func TestForumReadSameSecondAsPostCountsAsRead(t *testing.T) {
	db := testDB(t)
	now := time.Now().UTC()
	posted := now.Format("2006-01-02T15:04:05")
	if err := db.MarkForumRead("r", "alice", "t1", posted); err != nil {
		t.Fatal(err)
	}
	if read, _ := db.ForumRead("r", "alice", map[string]string{"t1": posted}); !read["t1"] {
		t.Fatal("a post from the second the reader viewed must count as seen")
	}
	// The next second is unambiguously later, and re-brightens.
	later := now.Add(time.Second).Format("2006-01-02T15:04:05")
	if read, _ := db.ForumRead("r", "alice", map[string]string{"t1": later}); read["t1"] {
		t.Fatal("a reply in a later second must make the thread unread")
	}
}