Artifact 8a9c699c79af36e7d8313a03d5b2b707768637ef38e3727633164f85249c498d:
- File
internal/harness/forumread_test.go
— part of check-in
[c7bf7c08f0]
at
2026-08-25 22:15:11
on branch trunk
— Forum read-state: break the same-second tie, and prove it end to end
Marks were stored second-granular like index post dates, so opening a thread in the same second its newest post landed left the thread the reader was looking at unread. Marks now carry a fractional part, which makes the two orderable: a post from the view's own second was in the page and reads as seen; any later second re-brightens.
Adds a harness test that walks the whole loop, including the part worth pinning — forum.get does not mark read, only forum.read does. (user: dmestas, size: 4621) [annotate] [blame] [check-ins using]
package harness import ( "encoding/json" "io" "net/http" "path/filepath" "strings" "testing" "time" "craftdesign.group/quarry/internal/api" "craftdesign.group/quarry/internal/oltp" "craftdesign.group/quarry/internal/ops" ) // TestForumReadStateEndToEnd walks read-state the way a reader does: // a thread they have never opened is unread, marking it read mutes it, // a reply un-mutes it, and none of it is visible to anyone else — or to // nobody at all, which is what an anonymous list is. func TestForumReadStateEndToEnd(t *testing.T) { stores := map[string]string{"alpha": buildStore(t, "readseed")} dataDir := t.TempDir() srv := ingestAndServeAuth(t, stores, dataDir, func(repo, user, pass string, need ops.Auth) api.Decision { if user == "" { return api.Denied } return api.Allowed }) odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db")) if err != nil { t.Fatal(err) } defer odb.Close() must := func(err error) { t.Helper() if err != nil { t.Fatal(err) } } must(odb.AddAccount("ann", "pw12345678", false)) must(odb.SetCaps("ann", "alpha", "23")) must(odb.AddAccount("bo", "pw12345678", false)) must(odb.SetCaps("bo", "alpha", "23")) call := func(user, method, path, body string) (int, []byte) { t.Helper() var r io.Reader if body != "" { r = strings.NewReader(body) } req, _ := http.NewRequest(method, srv.URL+path, r) if user != "" { req.SetBasicAuth(user, "pw12345678") } if body != "" { req.Header.Set("Content-Type", "application/x-www-form-urlencoded") } resp, err := srv.Client().Do(req) if err != nil { t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp.StatusCode, b } // readOf returns the read flag one caller sees for one thread. readOf := func(user, root string) bool { t.Helper() code, body := call(user, "GET", "/api/v1/r/alpha/forum", "") if code != http.StatusOK { t.Fatalf("forum.list as %q: %d", user, code) } var out struct { Threads []struct { Root string `json:"root"` Read bool `json:"read"` } `json:"threads"` } if err := json.Unmarshal(body, &out); err != nil { t.Fatal(err) } for _, th := range out.Threads { if strings.HasPrefix(th.Root, root) { return th.Read } } t.Fatalf("thread %q absent from the list as %q", root, user) return false } code, body := call("ann", "POST", "/api/v1/r/alpha/forum", "title=a+thread&body=opening+post") if code != http.StatusOK { t.Fatalf("forum.post: %d: %s", code, body) } var posted struct { Hash string `json:"hash"` } if err := json.Unmarshal(body, &posted); err != nil { t.Fatal(err) } root := posted.Hash // Nobody has opened it, so nobody has read it — the author very // much included. Authorship is not reading. if readOf("ann", root) { t.Fatal("a thread nobody opened came back read") } // Fetching the thread must NOT mark it read: forum.get is a query, // and an API client reading a thread is not a person seeing it. if code, _ := call("ann", "GET", "/api/v1/r/alpha/forum/"+root[:16], ""); code != http.StatusOK { t.Fatalf("forum.get: %d", code) } if readOf("ann", root) { t.Fatal("forum.get marked the thread read; only forum.read may do that") } if code, body := call("ann", "POST", "/api/v1/r/alpha/forum/read", "root="+root[:16]); code != http.StatusOK { t.Fatalf("forum.read: %d: %s", code, body) } if !readOf("ann", root) { t.Fatal("after forum.read the thread must come back read") } // One reader's state is their own. if readOf("bo", root) { t.Fatal("ann reading the thread marked it read for bo") } // A reply lands. Nothing touched ann's row, but the thread is unread // again because read is a comparison, not a flag. // // The wait is load-bearing, not flake insurance: index post dates // are second-granular, so a reply written in the same second as the // mark above is genuinely indistinguishable from one that arrived // before it. Crossing a second boundary is what makes this reply // unambiguously later. time.Sleep(1100 * time.Millisecond) if code, body := call("bo", "POST", "/api/v1/r/alpha/forum/"+root[:16], "body=a+reply"); code != http.StatusOK { t.Fatalf("forum.reply: %d: %s", code, body) } if readOf("ann", root) { t.Fatal("a new reply must make a read thread unread again") } // The sweep covers what is there now. if code, body := call("ann", "POST", "/api/v1/r/alpha/forum/read", ""); code != http.StatusOK { t.Fatalf("forum.read (all): %d: %s", code, body) } if !readOf("ann", root) { t.Fatal("mark-all-read left a thread unread") } }