package web
import (
"net/http/httptest"
"strings"
"testing"
"time"
)
// teamFakeLive serves one merged room: whatever it is given, in order.
type teamFakeLive struct{ msgs []ChatMessage }
func (f teamFakeLive) Tail(string, uint64) (<-chan ChatMessage, func()) {
ch := make(chan ChatMessage)
close(ch)
return ch, func() {}
}
func (f teamFakeLive) Join(string, string) func() { return func() {} }
func (f teamFakeLive) Present(string) []string { return nil }
func (f teamFakeLive) PresenceChanged(string) (<-chan struct{}, func()) {
return make(chan struct{}), func() {}
}
func (f teamFakeLive) Activity() (<-chan struct{}, func()) {
return make(chan struct{}), func() {}
}
func (f teamFakeLive) TailTeam(string) (<-chan ChatMessage, func()) {
ch := make(chan ChatMessage, len(f.msgs))
for _, m := range f.msgs {
ch <- m
}
close(ch)
return ch, func() {}
}
// A line arriving live is the same markup as the same line after a
// reload — including the origin chip, which comes off the MESSAGE and
// not off the page. The room used to look like a live channel and not
// be one; this is what made it one.
func TestTheTeamRoomAppendsArrivingLinesThroughThePageRenderer(t *testing.T) {
live := teamFakeLive{msgs: []ChatMessage{
// The SAME author, the SAME minute, two different channels —
// the only shape that can tell "runs group by author" apart
// from "runs never span two channels".
{Seq: 4, Kind: "msg", User: "lou", Repo: "zit", Text: "in zit", At: "2026-09-14T17:00:00Z"},
{Seq: 9, Kind: "msg", User: "lou", ToTeam: true, Text: "to everyone", At: "2026-09-14T17:00:00Z"},
}}
req := httptest.NewRequest("GET", "/t/core/chat.sse", nil)
w := httptest.NewRecorder()
done := make(chan struct{})
go func() { defer close(done); liveTeamRoom(live, "core", w, req) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the stream did not finish when the channel closed")
}
body := w.Body.String()
if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" {
t.Errorf("FAIL: content type = %q", ct)
}
for _, want := range []string{
"event: datastar-patch-elements", "data: mode append",
"data: selector #chatlog",
// the shared row, and each line's own origin
`class="chatmsg"`,
`data-hook="chat-origin" href="/r/zit"`,
`data-hook="chat-origin" data-to-team`,
} {
if !strings.Contains(body, want) {
t.Errorf("FAIL: the stream is missing %q", want)
}
}
// A run never spans two channels, so the second line is NOT drawn
// as a continuation of the first even though it is a minute apart.
if strings.Contains(body, "data-grouped") {
t.Errorf("FAIL: two channels were grouped into one run")
}
}
// Tombstones are skipped: each channel numbers its own sequences, so a
// merged view cannot address the message an edit or delete refers to.
// Reloading shows the folded truth; appending the tombstone would not.
func TestTheTeamRoomDoesNotAppendTombstones(t *testing.T) {
live := teamFakeLive{msgs: []ChatMessage{
{Seq: 5, Kind: "del", Ref: 4, User: "lou", Repo: "zit"},
{Seq: 6, Kind: "edit", Ref: 4, User: "lou", Repo: "zit", Text: "fixed"},
}}
req := httptest.NewRequest("GET", "/t/core/chat.sse", nil)
w := httptest.NewRecorder()
done := make(chan struct{})
go func() { defer close(done); liveTeamRoom(live, "core", w, req) }()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("the stream did not finish")
}
if strings.Contains(w.Body.String(), "datastar-patch-elements") {
t.Errorf("FAIL: a tombstone was appended as a row")
}
}