Skip to content
quarry Artifact [8a9c699c79]
Login

Artifact [8a9c699c79]

Artifact 8a9c699c79af36e7d8313a03d5b2b707768637ef38e3727633164f85249c498d:


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