Skip to content
quarry Artifact [03ac5d1957]
Login

Artifact [03ac5d1957]

Artifact 03ac5d19578bd342a7ba765703e21c87847f6f561179ff960f746b576773a6c3:


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