// chat_test.go proves RFC 0011 Phase 1 end to end: a real chat plane
// (embedded NATS) behind the real ops, reachable only through HTTP —
// the same path a client outside this process would use. No web layer
// is mounted here; these are API-only proofs.
package harness
import (
"bufio"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/chat"
"craftdesign.group/quarry/internal/events"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
)
// chatServer wires a repo-less forge with just the chat plane and an
// OLTP for read-state, capability-gated exactly as production gates
// chat.post (Token + C). "dan" and "eve" both hold C on repo "sqlite";
// "admin" additionally holds "a".
func chatServer(t *testing.T) (*httptest.Server, string) {
t.Helper()
dataDir := t.TempDir()
ev, err := events.Start(filepath.Join(dataDir, "nats"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(ev.Close)
room, err := chat.New(ev.Conn(), ev.JS())
if err != nil {
t.Fatal(err)
}
odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { odb.Close() })
for _, acct := range []struct{ name, caps string }{
{"dan", "C"}, {"eve", "C"}, {"admin", "Ca"},
} {
if err := odb.AddAccount(acct.name, "pw12345678", false); err != nil {
t.Fatal(err)
}
if err := odb.SetCaps(acct.name, "sqlite", acct.caps); err != nil {
t.Fatal(err)
}
}
repos := api.NewRepos(nil)
repos.Put("sqlite", &api.Repo{Code: "sqlite"})
capsFor := func(account, repo string) api.CapSet { return odb.EffectiveCaps(account, repo) }
deps := api.Deps{Version: "0.0.0-harness", Repos: repos, Chat: room, Forum: odb,
CanRead: func(_ context.Context, name string) bool { return name == "sqlite" },
CapsFor: capsFor}
r := ops.NewRegistry()
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
verify := func(repo, user, pass string, need ops.Auth) api.Decision {
if user == "" || pass != "pw12345678" {
return api.Denied
}
return api.Allowed
}
mux, err := api.RouterWithCaps(r, "0.0.0-harness", capsFor, verify)
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, dataDir
}
type chatCaller struct {
t *testing.T
srv *httptest.Server
user string
}
func (c chatCaller) do(method, path string, form url.Values) (int, []byte) {
c.t.Helper()
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequest(method, c.srv.URL+path, body)
if err != nil {
c.t.Fatal(err)
}
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if c.user != "" {
req.SetBasicAuth(c.user, "pw12345678")
}
resp, err := c.srv.Client().Do(req)
if err != nil {
c.t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, b
}
func (c chatCaller) post(text string, ref uint64) (uint64, int) {
c.t.Helper()
form := url.Values{"text": {text}}
if ref != 0 {
form.Set("ref", strconv.FormatUint(ref, 10))
}
code, body := c.do("POST", "/api/v1/r/sqlite/chat", form)
if code != http.StatusOK {
return 0, code
}
var out struct {
Seq uint64 `json:"seq"`
}
json.Unmarshal(body, &out)
return out.Seq, code
}
// Posting, disconnecting, posting twice more, then reconnecting with
// Last-Event-ID replays exactly what was missed, in order, with
// increasing ids — the "cannot lose a message" property in one flow.
func TestChatStreamNeverLosesAMessage(t *testing.T) {
srv, _ := chatServer(t)
dan := chatCaller{t: t, srv: srv, user: "dan"}
seq1, code := dan.post("first", 0)
if code != http.StatusOK || seq1 == 0 {
t.Fatalf("post 1: code=%d seq=%d", code, seq1)
}
// Open a stream, immediately close it (simulating a hidden tab),
// then post twice more while nobody is listening. Connection reuse
// would let the first, abandoned stream's still-running handler
// share a socket with the second request; forcing a fresh
// connection per request is what makes "closed" actually mean
// closed here, same as a real browser dropping the tab.
client := &http.Client{Transport: &http.Transport{DisableKeepAlives: true}}
req, _ := http.NewRequest("GET", srv.URL+"/api/v1/r/sqlite/chat/stream", nil)
req.SetBasicAuth("dan", "pw12345678")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close() // close before anything is posted
seq2, code := dan.post("second", 0)
if code != http.StatusOK {
t.Fatalf("post 2: code=%d", code)
}
seq3, code := dan.post("third", 0)
if code != http.StatusOK {
t.Fatalf("post 3: code=%d", code)
}
req2, _ := http.NewRequest("GET", srv.URL+"/api/v1/r/sqlite/chat/stream", nil)
req2.Header.Set("Last-Event-ID", strconv.FormatUint(seq1, 10))
req2.SetBasicAuth("dan", "pw12345678")
resp2, err := client.Do(req2)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
var ids []uint64
rd := bufio.NewReader(resp2.Body)
deadline := time.After(10 * time.Second)
for len(ids) < 2 {
lineCh := make(chan string, 1)
go func() { l, _ := rd.ReadString('\n'); lineCh <- l }()
select {
case l := <-lineCh:
if strings.HasPrefix(l, "id: ") {
n, _ := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(l, "id: ")), 10, 64)
ids = append(ids, n)
}
case <-deadline:
t.Fatalf("only got ids %v, wanted seq2=%d then seq3=%d", ids, seq2, seq3)
}
}
if ids[0] != seq2 || ids[1] != seq3 {
t.Fatalf("resumed stream ids = %v, want [%d %d] in order", ids, seq2, seq3)
}
}
// An edit inside the 5-minute window replaces the text chat.get shows;
// a delete tombstones it. Both an anonymous edit and editing someone
// else's message are refused; an "a" caller may delete anyone's.
func TestChatEditDeleteAuthzAndFold(t *testing.T) {
srv, _ := chatServer(t)
dan := chatCaller{t: t, srv: srv, user: "dan"}
eve := chatCaller{t: t, srv: srv, user: "eve"}
admin := chatCaller{t: t, srv: srv, user: "admin"}
anon := chatCaller{t: t, srv: srv}
seq, code := dan.post("original", 0)
if code != http.StatusOK {
t.Fatalf("post: %d", code)
}
if code, _ := anon.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/edit",
url.Values{"text": {"hijack"}}); code == http.StatusOK {
t.Fatal("an anonymous edit must be refused")
}
if code, _ := eve.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/edit",
url.Values{"text": {"hijack"}}); code == http.StatusOK {
t.Fatal("editing someone else's message must be refused")
}
if code, _ := dan.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/edit",
url.Values{"text": {"edited"}}); code != http.StatusOK {
t.Fatalf("own edit within the window: %d", code)
}
getSeq := func(seq uint64) struct {
Message struct {
Text string `json:"text"`
Edited bool `json:"edited"`
Deleted bool `json:"deleted"`
} `json:"message"`
} {
t.Helper()
// chat.get requires "C" like every other chat surface, so a
// signed-in reader fetches it (this repo has no public-read
// default configured); anonymous refusal is a separate concern
// this test isn't proving.
code, body := dan.do("GET", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10), nil)
if code != http.StatusOK {
t.Fatalf("chat.get: %d", code)
}
var out struct {
Message struct {
Text string `json:"text"`
Edited bool `json:"edited"`
Deleted bool `json:"deleted"`
} `json:"message"`
}
json.Unmarshal(body, &out)
return out
}
if g := getSeq(seq); g.Message.Text != "edited" || !g.Message.Edited {
t.Fatalf("after edit: %+v", g.Message)
}
if code, _ := eve.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/delete", nil); code == http.StatusOK {
t.Fatal("deleting someone else's message without 'a' must be refused")
}
if code, _ := admin.do("POST", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(seq, 10)+"/delete", nil); code != http.StatusOK {
t.Fatalf("admin (a) delete: %d", code)
}
if g := getSeq(seq); !g.Message.Deleted || g.Message.Text != "" {
t.Fatalf("after admin delete: %+v", g.Message)
}
}
// chat.read clears the account's unread count for that repo, and a
// subsequent post raises it again — read is a comparison, not a flag.
func TestChatReadClearsAndRepostRaisesUnread(t *testing.T) {
srv, _ := chatServer(t)
dan := chatCaller{t: t, srv: srv, user: "dan"}
eve := chatCaller{t: t, srv: srv, user: "eve"}
unreadFor := func(c chatCaller) (unread int, present bool) {
t.Helper()
code, body := c.do("GET", "/api/v1/me/chat/unread", nil)
if code != http.StatusOK {
t.Fatalf("chat.unread: %d", code)
}
var out struct {
Repos map[string]struct {
Unread int `json:"unread"`
} `json:"repos"`
}
json.Unmarshal(body, &out)
r, ok := out.Repos["sqlite"]
return r.Unread, ok
}
if _, code := dan.post("hello", 0); code != http.StatusOK {
t.Fatalf("post: %d", code)
}
if unread, present := unreadFor(eve); !present || unread < 1 {
t.Fatalf("eve's unread after dan's post = %d (present=%v), want >=1", unread, present)
}
if code, _ := eve.do("POST", "/api/v1/r/sqlite/chat/read", nil); code != http.StatusOK {
t.Fatalf("chat.read: %d", code)
}
if unread, present := unreadFor(eve); present && unread > 0 {
t.Fatalf("eve's unread after marking read = %d (present=%v), want 0/absent", unread, present)
}
if _, code := dan.post("again", 0); code != http.StatusOK {
t.Fatalf("post: %d", code)
}
if unread, present := unreadFor(eve); !present || unread < 1 {
t.Fatalf("a new post must re-raise unread; got %d (present=%v)", unread, present)
}
}
// A reply's ref is recorded and comes back through chat.get's replies;
// a reply to a nonexistent seq is refused.
func TestChatReplyRecordedAndValidated(t *testing.T) {
srv, _ := chatServer(t)
dan := chatCaller{t: t, srv: srv, user: "dan"}
root, code := dan.post("root message", 0)
if code != http.StatusOK {
t.Fatalf("post root: %d", code)
}
if _, code := dan.post("a reply", root); code != http.StatusOK {
t.Fatalf("post reply: %d", code)
}
if _, code := dan.post("bad reply", 999999); code == http.StatusOK {
t.Fatal("a reply to a nonexistent seq must be refused")
}
code, body := dan.do("GET", "/api/v1/r/sqlite/chat/"+strconv.FormatUint(root, 10), nil)
if code != http.StatusOK {
t.Fatalf("chat.get: %d", code)
}
var out struct {
Replies []struct {
Text string `json:"text"`
} `json:"replies"`
}
json.Unmarshal(body, &out)
if len(out.Replies) != 1 || out.Replies[0].Text != "a reply" {
t.Fatalf("replies = %+v", out.Replies)
}
}