oficina / meet Public

room: page, removal and takeover confirm pages, participation-or-standing access, the scriptless exception

Adapts task-6-brief.md to the branch's current hub/session shapes:
sessionKey now returns (string, bool), so roomAccess/remove/takeover
each check it defensively and log-and-500 on a missing subject, the
same pattern stream.go and signal.go already use. hub.Remove and
hub.Takeover signatures match the brief; any error from either maps
to 404, never 403 (ErrNotYours, ErrUnknownPeer, ErrNotAdmitted,
ErrNotMember all mean 'not a peer you may act on').

sseReader.nextOf did not exist yet (task-11-brief.md lists it as
already present from tasks 4/5, but it wasn't); added it to
sse_test.go since room_test.go needs it.

Two of the brief's test lines used client.get on /stream where the
handler's EventSource-Accept gate (ruling 15, committed after the
plan was written) refuses a plain GET before it reaches the
conflict/resume logic being asserted; switched those two lines to
client.getStream, which sends the Accept header, matching what those
assertions actually need to exercise.

room.html's data-* block gained a fifth attribute, data-peer=,
that the brief's Produces line lists but its own template snippet
omitted.

confirmRemove's copy names the meeting, not the removed person: the
hub has no exported way to look up a peer's name from another
peer's session, and this task does not touch hub files to add one.
Flagged for the reviewer; spec §3.5 asks for the person's name.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev e4d260b66a4a36bbde92c24915501abef68f4ac6 parent 096ee55
11 files changed, +426 −25
  • AGENTS.md +5 −2
  • internal/meet/app.go +5 −0
  • internal/meet/handlers_join.go +5 −22
  • internal/meet/handlers_room.go +149 −0
  • internal/meet/render.go +1 −1
  • internal/meet/static/meet.css +95 −0
  • internal/meet/static/room.js +9 −0
  • internal/meet/templates/join.html +2 −0
  • internal/meet/templates/room.html +22 −0
  • internal/meettest/room_test.go +115 −0
  • internal/meettest/sse_test.go +18 −0

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 6d355c4..7467ca3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,8 +47,11 @@ mechanically.
a rename is indistinguishable from a drop plus an add.
- Money is integer cents. A float never touches a value a person will
be held to.
-- Screens work with JavaScript disabled; destructive actions get their
- own confirm-page URL.
+- Screens work with JavaScript disabled, with one exception: browser
+ media participation (the holding screen's camera preview and join
+ button, and the call itself in the room) needs script; both pages
+ render their title, standing, floor notice and navigation without it.
+ Destructive actions get their own confirm-page URL.
- The gate is `make ci` (vet + gofmt + tests) — the same definition
CI runs. Run it before every push. `CGO_ENABLED=0` throughout: the
stack is cgo-free by design.
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 9c2fc82..75bc5ea 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -307,6 +307,11 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Get("/continue", a.continueSignedIn)
r.Get("/welcome", a.welcome)
r.Get("/m/{sqid}/join", a.holding)
+ r.Get("/m/{sqid}", a.room)
+ r.Get("/m/{sqid}/remove/{peer}", a.confirmRemove)
+ r.Post("/m/{sqid}/remove/{peer}", a.remove)
+ r.Get("/m/{sqid}/takeover", a.confirmTakeover)
+ r.Post("/m/{sqid}/takeover", a.takeover)
r.Group(func(r chi.Router) {
r.Use(a.requireMember)
r.Get("/", a.index)
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go
index 6fdfda5..185514e 100644
--- a/internal/meet/handlers_join.go
+++ b/internal/meet/handlers_join.go
@@ -52,29 +52,12 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther)
}
-// holding is GET /m/{sqid}/join, authorized by standing (spec §2.5). In
-// this slice it renders the server-side half of the holding screen and
-// says the room is the next slice.
+// holding is GET /m/{sqid}/join, authorized by standing or a live
+// participation for this session (spec §2.5), the same row the room
+// page uses.
func (a *app) holding(w http.ResponseWriter, r *http.Request) {
- m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid"))
- if err != nil {
- a.logger.Error("meet: load meeting", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
- return
- }
- if !ok || m.EndedAt != nil {
- ErrorPage(w, r, http.StatusNotFound, "")
- return
- }
- p, _ := currentPerson(r)
- st, err := a.standingFor(r.Context(), p, m)
- if err != nil {
- a.logger.Error("meet: standing", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
- return
- }
- if st == StandingNone {
- ErrorPage(w, r, http.StatusNotFound, "")
+ m, p, st, ok := a.roomAccess(w, r)
+ if !ok {
return
}
render(w, "join", page(r, map[string]any{
diff --git a/internal/meet/handlers_room.go b/internal/meet/handlers_room.go
new file mode 100644
index 0000000..e178624
--- /dev/null
+++ b/internal/meet/handlers_room.go
@@ -0,0 +1,149 @@
+package meet
+
+import (
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/oficina/meet/internal/meet/hub"
+)
+
+// roomAccess is §2.5's row for the two pages: standing, or a live
+// participation for this session in this room.
+func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Person, Standing, bool) {
+ sqid := chi.URLParam(r, "sqid")
+ m, ok, err := a.store.meetingBySqid(r.Context(), sqid)
+ if err != nil {
+ a.logger.Error("meet: load meeting", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return Meeting{}, Person{}, StandingNone, false
+ }
+ if !ok || m.EndedAt != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return Meeting{}, Person{}, StandingNone, false
+ }
+ p, _ := currentPerson(r)
+ st, err := a.standingFor(r.Context(), p, m)
+ if err != nil {
+ a.logger.Error("meet: standing", "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return Meeting{}, Person{}, StandingNone, false
+ }
+ if st == StandingNone {
+ session, ok := a.sessionKey(r)
+ if !ok {
+ a.logger.Error("meet: room access with no session subject", "path", r.URL.Path)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return Meeting{}, Person{}, StandingNone, false
+ }
+ if _, live := a.hub.LiveForSession(sqid, session); !live {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return Meeting{}, Person{}, StandingNone, false
+ }
+ st = StandingGuest
+ }
+ return m, p, st, true
+}
+
+// room is GET /m/{sqid}: standing or live participation (spec §2.5,
+// §3.4). Scriptless it renders the header, the standing line and a link
+// back to the holding screen; room.js reveals the call for a browser
+// that has script.
+func (a *app) room(w http.ResponseWriter, r *http.Request) {
+ m, p, st, ok := a.roomAccess(w, r)
+ if !ok {
+ return
+ }
+ render(w, "room", page(r, map[string]any{
+ "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
+ "Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
+ "Floor": !a.cfg.RelayConfigured, "Boot": a.hub.Boot(),
+ }))
+}
+
+// confirmRemove is GET /m/{sqid}/remove/{peer}: its own URL, per the
+// family's rule for a destructive action (spec §3.5). Standing member is
+// enough to see the page; hub.Remove is the actual authority, checked at
+// execution.
+func (a *app) confirmRemove(w http.ResponseWriter, r *http.Request) {
+ m, _, st, ok := a.roomAccess(w, r)
+ if !ok {
+ return
+ }
+ if st != StandingMember {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ target := chi.URLParam(r, "peer")
+ render(w, "confirm", page(r, map[string]any{
+ "Title": "Remove this participant?",
+ "Body": "They will be disconnected from \"" + a.store.title(m) + "\" and can only come back through the meeting link.",
+ "Action": "/m/" + m.Sqid + "/remove/" + target, "Label": "Remove",
+ "CancelHref": "/m/" + m.Sqid, "Sqid": m.Sqid,
+ }))
+}
+
+// remove is POST /m/{sqid}/remove/{peer}: hub.Remove is the only
+// authority (spec §3.5), re-derived at execution. Its ErrNotYours,
+// ErrUnknownPeer, ErrNotAdmitted and ErrNotMember all mean the same
+// thing to the caller — this is not a peer they may remove — and all
+// answer 404, never 403 (spec §2.5).
+func (a *app) remove(w http.ResponseWriter, r *http.Request) {
+ m, _, _, ok := a.roomAccess(w, r)
+ if !ok {
+ return
+ }
+ session, ok := a.sessionKey(r)
+ if !ok {
+ a.logger.Error("meet: remove with no session subject", "path", r.URL.Path)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ actor, live := a.hub.LiveForSession(m.Sqid, session)
+ if !live {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ if err := a.hub.Remove(r.Context(), m.Sqid, session, actor, hub.PeerID(chi.URLParam(r, "peer"))); err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther)
+}
+
+// confirmTakeover is GET /m/{sqid}/takeover: its own URL, so a GET never
+// takes over (spec §4.2). roomAccess's standing-or-participation row
+// covers the page; hub.Takeover is the authority for the POST.
+func (a *app) confirmTakeover(w http.ResponseWriter, r *http.Request) {
+ m, _, _, ok := a.roomAccess(w, r)
+ if !ok {
+ return
+ }
+ render(w, "confirm", page(r, map[string]any{
+ "Title": "This meeting is open in another tab",
+ "Body": "Use this tab instead? The other tab will leave the call.",
+ "Action": "/m/" + m.Sqid + "/takeover", "Label": "Use this tab",
+ "CancelHref": "/m/" + m.Sqid + "/join", "Sqid": m.Sqid,
+ }))
+}
+
+// takeover is POST /m/{sqid}/takeover: a confirmed action on the
+// session's own live participation only (spec §4.2), never another
+// person's.
+func (a *app) takeover(w http.ResponseWriter, r *http.Request) {
+ m, _, _, ok := a.roomAccess(w, r)
+ if !ok {
+ return
+ }
+ session, ok := a.sessionKey(r)
+ if !ok {
+ a.logger.Error("meet: takeover with no session subject", "path", r.URL.Path)
+ ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ if _, err := a.hub.Takeover(m.Sqid, session); err != nil {
+ ErrorPage(w, r, http.StatusNotFound, "")
+ return
+ }
+ http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther)
+}
diff --git a/internal/meet/render.go b/internal/meet/render.go
index 81ea640..81bcea3 100644
--- a/internal/meet/render.go
+++ b/internal/meet/render.go
@@ -17,7 +17,7 @@ var appFS embed.FS
var assets = rastrillo.NewAssets(appFS)
-var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join"}
+var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room"}
var pages = map[string]*template.Template{}
diff --git a/internal/meet/static/meet.css b/internal/meet/static/meet.css
new file mode 100644
index 0000000..e0288bb
--- /dev/null
+++ b/internal/meet/static/meet.css
@@ -0,0 +1,95 @@
+/* meet.css: the room and the holding screen's scripted parts. Small on
+ purpose (spec §9's three hand-written scripts get their own styling
+ as they grow); everything here reads from rst-* tokens so it follows
+ the theme rather than fixing its own colours. */
+
+.meet-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr));
+ gap: var(--rst-sp-3);
+ margin-block: var(--rst-sp-4);
+}
+
+.meet-tile {
+ position: relative;
+ aspect-ratio: 4 / 3;
+ background: var(--rst-surface-2);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ overflow: hidden;
+}
+
+.meet-tile video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.meet-tile-name {
+ position: absolute;
+ inset-block-end: var(--rst-sp-2);
+ inset-inline-start: var(--rst-sp-2);
+ padding: var(--rst-sp-1) var(--rst-sp-2);
+ background: var(--rst-surface);
+ border-radius: var(--rst-radius-sm);
+ color: var(--rst-text);
+ font-size: var(--rst-fs-sm);
+}
+
+.meet-controls {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-2);
+ margin-block-end: var(--rst-sp-4);
+}
+
+.meet-queue {
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ padding: var(--rst-sp-3);
+ margin-block-end: var(--rst-sp-4);
+}
+
+.meet-queue h2 {
+ margin: 0 0 var(--rst-sp-2);
+ font-size: var(--rst-fs-base);
+ color: var(--rst-text-muted);
+}
+
+.meet-queue ul {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--rst-sp-2);
+}
+
+.meet-queue li {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--rst-sp-2);
+}
+
+.meet-preview {
+ width: 100%;
+ max-width: 24rem;
+ aspect-ratio: 4 / 3;
+ background: var(--rst-surface-2);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ object-fit: cover;
+}
+
+.meet-pickers {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-3);
+ margin-block: var(--rst-sp-3);
+}
+
+.meet-status {
+ color: var(--rst-text-muted);
+ min-height: 1.2em;
+}
diff --git a/internal/meet/static/room.js b/internal/meet/static/room.js
new file mode 100644
index 0000000..03dd93e
--- /dev/null
+++ b/internal/meet/static/room.js
@@ -0,0 +1,9 @@
+// room.js: placeholder. Task 8 replaces this with the real signalling,
+// resume and mesh logic; for now it only reveals the scripted room so a
+// browser with script sees more than the scriptless fallback.
+(() => {
+ 'use strict';
+ const root = document.getElementById('room');
+ if (!root) return;
+ root.hidden = false;
+})();
diff --git a/internal/meet/templates/join.html b/internal/meet/templates/join.html
index cd759b6..59fd75c 100644
--- a/internal/meet/templates/join.html
+++ b/internal/meet/templates/join.html
@@ -6,5 +6,7 @@
<p>You are {{.Name}}, joining as a {{.Standing}}.</p>
{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
<p>Calls coming soon</p>
+<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.</p></noscript>
+<div id="devices" data-sqid="{{.Sqid}}" hidden></div>
{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}}
{{end}}
diff --git a/internal/meet/templates/room.html b/internal/meet/templates/room.html
new file mode 100644
index 0000000..408821d
--- /dev/null
+++ b/internal/meet/templates/room.html
@@ -0,0 +1,22 @@
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}
+{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}}
+{{template "page-header" dict "Title" .Title}}
+{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
+<p>You are {{.Name}}, in the call as a {{.Standing}}.</p>
+{{if .Floor}}<p rst-notice>This team's Meet has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
+<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p></noscript>
+<div id="room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-peer="" hidden>
+ <div id="tiles" class="meet-grid"></div>
+ <div id="controls" class="meet-controls">
+ <button type="button" id="mute" rst-btn>Mute</button>
+ <button type="button" id="camera" rst-btn>Camera off</button>
+ <button type="button" id="leave" rst-btn="danger">Leave</button>
+ </div>
+ <div id="queue" class="meet-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
+ <p id="status" class="meet-status" role="status"></p>
+</div>
+{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
+<link rel="stylesheet" href="{{asset "static/meet.css"}}">
+<script defer src="{{asset "static/room.js"}}"></script>
+{{end}}
diff --git a/internal/meettest/room_test.go b/internal/meettest/room_test.go
new file mode 100644
index 0000000..5a31962
--- /dev/null
+++ b/internal/meettest/room_test.go
@@ -0,0 +1,115 @@
+package meettest
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+)
+
+func TestRoomPageIsScriptlessHonestAndStandingGated(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ rec := c.get("/m/" + sqid)
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK || !strings.Contains(body, "Board meeting") || !strings.Contains(body, `data-sqid="`+sqid+`"`) || !strings.Contains(body, "/m/"+sqid+"/join") {
+ t.Fatalf("room page: %d\n%s", rec.Code, body)
+ }
+ if !strings.Contains(body, "<noscript>") || !strings.Contains(body, "needs JavaScript") {
+ t.Fatal("room page lacks its scriptless paragraph")
+ }
+ stranger := newHarness(t).browser(t)
+ stranger.signIn("nobody@example.com")
+ if rec := stranger.get("/m/" + sqid); rec.Code != http.StatusNotFound {
+ t.Fatalf("no standing: %d", rec.Code)
+ }
+}
+
+func TestRemoveNeedsAConfirmPageAndAnAdmittedMemberActor(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ m := x.browser(t)
+ m.signIn("paul@example.com")
+ sm := m.stream("/m/" + sqid + "/stream")
+ defer sm.close()
+ pm, _ := sm.hello()
+ m.signal(sqid, pm, `{"type":"join"}`)
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.signIn("guest@example.com")
+ sg := g.stream("/m/" + sqid + "/stream")
+ defer sg.close()
+ pg, _ := sg.hello()
+ g.signal(sqid, pg, `{"type":"join"}`)
+ if rec := m.get("/m/" + sqid + "/remove/" + pg); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "Remove") {
+ t.Fatalf("confirm page: %d", rec.Code)
+ }
+ if rec := g.post("/m/"+sqid+"/remove/"+pm, nil); rec.Code != http.StatusNotFound {
+ t.Fatalf("waiting guest removing the member: %d", rec.Code)
+ }
+ if rec := m.post("/m/"+sqid+"/remove/"+pg, nil); rec.Code != http.StatusSeeOther {
+ t.Fatalf("member removing: %d", rec.Code)
+ }
+ ev, data, ok := sg.nextOf("lost", 2)
+ if !ok || !strings.Contains(string(data), `"removed"`) {
+ t.Fatalf("removed guest got %s %s", ev, data)
+ }
+}
+
+func TestTakeoverIsAConfirmedPostAndAGetEndsNothing(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ m := x.browser(t)
+ m.signIn("paul@example.com")
+ s1 := m.stream("/m/" + sqid + "/stream")
+ defer s1.close()
+ p1, _ := s1.hello()
+ m.signal(sqid, p1, `{"type":"join"}`)
+ // A plain GET at the stream endpoint, EventSource's own Accept
+ // header and all, must not let a query parameter perform a takeover:
+ // the session already holds p1, so this is the ordinary
+ // already-open conflict (stream.go, unmodified by this task), never
+ // hub.Takeover. getStream, not get: this needs the Accept header
+ // EventSource always sends (ruling 15) to reach that check at all.
+ if rec := m.getStream("/m/" + sqid + "/stream?takeover=1"); rec.Code != http.StatusConflict {
+ t.Fatalf("a GET with a query parameter must not take over: %d", rec.Code)
+ }
+ if rec := m.get("/m/" + sqid + "/takeover"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "another tab") {
+ t.Fatalf("takeover confirm: %d", rec.Code)
+ }
+ req := httptestPost("/m/"+sqid+"/takeover", url.Values{})
+ req.Header.Set("Origin", "https://evil.example")
+ if rec := m.do(req); rec.Code != http.StatusForbidden {
+ t.Fatalf("cross-origin takeover: %d", rec.Code)
+ }
+ if rec := m.post("/m/"+sqid+"/takeover", nil); rec.Code != http.StatusSeeOther {
+ t.Fatalf("takeover: %d", rec.Code)
+ }
+ if ev, data, ok := s1.nextOf("lost", 2); !ok || !strings.Contains(string(data), `"replaced"`) {
+ t.Fatalf("old stream got %s %s", ev, data)
+ }
+ // getStream, not get, for the same reason as above: a resume is
+ // only ever attempted by EventSource, and the Accept-header gate
+ // (ruling 15) applies ahead of the resume branch too.
+ if rec := m.getStream("/m/" + sqid + "/stream?peer=" + p1); rec.Code != http.StatusGone {
+ t.Fatalf("resume of the replaced peer: %d, want 410", rec.Code)
+ }
+ s2 := m.stream("/m/" + sqid + "/stream")
+ defer s2.close()
+ if p2, _ := s2.hello(); p2 == "" || p2 == p1 {
+ t.Fatalf("fresh stream after takeover: %q", p2)
+ }
+}
+
+func TestEndingTheMeetingEndsTheRoom(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ m := x.browser(t)
+ m.signIn("paul@example.com")
+ s := m.stream("/m/" + sqid + "/stream")
+ defer s.close()
+ p, _ := s.hello()
+ m.signal(sqid, p, `{"type":"join"}`)
+ m.post("/meetings/"+sqid+"/end", nil)
+ if ev, data, ok := s.nextOf("lost", 2); !ok || !strings.Contains(string(data), `"ended"`) {
+ t.Fatalf("got %s %s", ev, data)
+ }
+}
diff --git a/internal/meettest/sse_test.go b/internal/meettest/sse_test.go
index 629d3e6..3c7f0df 100644
--- a/internal/meettest/sse_test.go
+++ b/internal/meettest/sse_test.go
@@ -151,6 +151,24 @@ func (s *sseReader) hello() (peer, boot string) {
return body.Peer, body.Boot
}
+// nextOf skips events until the named one arrives, or the timeout
+// passes — for a test that expects some event (a roster, a peer) ahead
+// of the one it actually wants to assert on.
+func (s *sseReader) nextOf(event string, timeoutSeconds int) (string, []byte, bool) {
+ s.t.Helper()
+ deadline := time.After(time.Duration(timeoutSeconds) * time.Second)
+ for {
+ select {
+ case ev := <-s.events:
+ if ev.name == event {
+ return ev.name, ev.data, true
+ }
+ case <-deadline:
+ return "", nil, false
+ }
+ }
+}
+
// waitHeartbeat waits for one SSE comment line, which is the only thing
// the server sends on an idle stream.
func (s *sseReader) waitHeartbeat(timeout time.Duration) bool {