package hub import ( "context" "io" "log/slog" "testing" "time" ) // testCtx is the context the four request-path methods now take, so a // member's standing can be re-derived on every request (spec §15.12). // Nothing in these tests cancels it: the standing they exercise comes // from testHub's StandingFor, not from the context. var testCtx = context.Background() func testHub(now *time.Time) *Hub { return New(Config{ Now: func() time.Time { return *now }, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), StandingFor: func(ctx context.Context, room string, personID int64) (Kind, bool, error) { if personID < 100 { return KindMember, true, nil } return KindGuest, true, nil }, }) } func TestOpenIssuesADistinctPeerBoundToTheSession(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) a, err := h.Open("r1", "sessA", 1, "Ann", KindMember) if err != nil { t.Fatal(err) } b, _ := h.Open("r1", "sessB", 2, "Bob", KindMember) if a.Peer == b.Peer || len(a.Peer) != 32 { t.Fatalf("peer ids %q %q", a.Peer, b.Peer) } if _, ok := h.Find("r1", "sessB", a.Peer); ok { t.Fatal("a peer was found through another session") } if _, ok := h.Find("r2", "sessA", a.Peer); ok { t.Fatal("a peer was found through another room") } if p, ok := h.Find("r1", "sessA", a.Peer); !ok || p.Admitted { t.Fatal("fresh participation should exist and be unjoined") } } func TestOneLivePeerPerSessionPerRoom(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) _, err := h.Open("r1", "sessA", 1, "Ann", KindMember) if err != ErrSessionHasPeer { t.Fatalf("second stream: %v", err) } h.Detach("r1", a.Peer, nil) if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != ErrSessionHasPeer { t.Fatal("a detached peer inside its resume window should still block a fresh stream") } now = now.Add(31 * time.Second) h.Tick(context.Background()) if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != nil { t.Fatalf("after the window: %v", err) } } func TestSixteenParticipationsPerRoomThenRefuse(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) for i := 0; i < 16; i++ { if _, err := h.Open("r1", "s"+string(rune('a'+i)), int64(100+i), "g", KindGuest); err != nil { t.Fatalf("open %d: %v", i, err) } } if _, err := h.Open("r1", "sZ", 200, "g", KindGuest); err != ErrRoomFull { t.Fatalf("seventeenth: %v", err) } } func TestUnjoinedParticipationDropsAfterThirtySeconds(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) now = now.Add(31 * time.Second) h.Tick(context.Background()) if _, ok := h.Find("r1", "sessA", a.Peer); ok { t.Fatal("unjoined participation survived the window") } if !a.Sub.Lost() { t.Fatal("its subscriber was not closed") } } func TestFourStreamsPerPerson(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) for i := 0; i < 4; i++ { if _, err := h.Open("r"+string(rune('1'+i)), "s"+string(rune('1'+i)), 1, "Ann", KindMember); err != nil { t.Fatal(err) } } if _, err := h.Open("r9", "s9", 1, "Ann", KindMember); err != ErrPersonStreams { t.Fatalf("fifth stream: %v", err) } } func TestSlowSubscriberIsDeclaredLost(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) for i := 0; i < 70; i++ { h.deliver("r1", a.Peer, Message{Type: "peer"}) } if !a.Sub.Lost() { t.Fatal("65th undelivered event did not declare the subscriber lost") } var last Message for m := range a.Sub.C() { last = m } if last.Type != "lost" || last.Reason != "buffer" { t.Fatalf("last event %+v, want lost/buffer", last) } } func TestTerminateDoesNotEvictWhenNotFull(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) h.deliver("r1", a.Peer, Message{Type: TypeGone, From: "someone"}) a.Sub.terminate(Message{Type: TypeLost, Reason: ReasonRemoved}) var got []Message for m := range a.Sub.C() { got = append(got, m) } if len(got) != 2 { t.Fatalf("got %d events %+v, want the queued one and the lost", len(got), got) } if got[0].Type != TypeGone || got[0].From != "someone" { t.Fatalf("first event %+v, want the queued gone", got[0]) } if got[1].Type != TypeLost || got[1].Reason != ReasonRemoved { t.Fatalf("second event %+v, want lost/removed", got[1]) } } // A second stream that resumes an attached participation displaces the // first (lost{replaced}, ruling above). The displaced handler then does // what every stream handler does on its way out — Detach — and that // must not take the replacement's attachment down with it. func TestDetachOfADisplacedStreamLeavesTheReplacementAttached(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) res, err := h.Resume(testCtx, "r1", "sa", a.Peer, nil) if err != nil { t.Fatal(err) } if ev := drain(a.Sub); len(ev) != 1 || ev[0].Reason != ReasonReplaced { t.Fatalf("displaced stream got %+v", ev) } h.Detach("r1", a.Peer, a.Sub) // the displaced handler, on its way out if res.Sub.Lost() { t.Fatal("the replacement stream was closed by the displaced one's Detach") } p, ok := h.Find("r1", "sa", a.Peer) if !ok || p.sub != res.Sub || !p.resumeUntil.IsZero() { t.Fatalf("participation lost its attachment: %+v", p) } // The replacement's own Detach still detaches. h.Detach("r1", a.Peer, res.Sub) if !res.Sub.Lost() || !p.resumeUntil.Equal(now.Add(ResumeWindow)) { t.Fatalf("the attached stream's Detach did nothing: %+v", p) } } // PeerNameFor is Remove's own authority, read rather than acted on: it // must answer a name in exactly the cases Remove would go on to act, // and false everywhere else, so a confirm page can never name — or // offer to remove — a peer the caller could not actually remove. func TestPeerNameForIsExactlyRemovesAuthority(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) ctx := context.Background() member, _ := h.Open("r1", "sMember", 1, "Ann", KindMember) if err := h.Join(ctx, "r1", "sMember", member.Peer); err != nil { t.Fatal(err) } target, _ := h.Open("r1", "sTarget", 2, "Bob", KindMember) if name, ok := h.PeerNameFor("r1", "sMember", target.Peer); !ok || name != "Bob" { t.Fatalf("member actor + present target: %q, %v", name, ok) } guest, _ := h.Open("r1", "sGuest", 100, "Cid", KindGuest) if err := h.Join(ctx, "r1", "sGuest", guest.Peer); err != nil { t.Fatal(err) } if p, ok := h.Find("r1", "sGuest", guest.Peer); !ok || p.Admitted { t.Fatal("the guest should be waiting, not admitted, for this case to test anything") } if _, ok := h.PeerNameFor("r1", "sGuest", target.Peer); ok { t.Fatal("a waiting guest is not admitted and must not name anyone") } other, _ := h.Open("r2", "sOther", 3, "Deb", KindMember) if err := h.Join(ctx, "r2", "sOther", other.Peer); err != nil { t.Fatal(err) } if _, ok := h.PeerNameFor("r1", "sMember", other.Peer); ok { t.Fatal("a target in another room must not be named") } if _, ok := h.PeerNameFor("r1", "sMember", member.Peer); ok { t.Fatal("the actor is not its own target") } if _, ok := h.PeerNameFor("r1", "sNobody", target.Peer); ok { t.Fatal("a session with no participation in this room must not name anyone") } }