| 1 | package hub |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "io" |
| 6 | "log/slog" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | // testCtx is the context the four request-path methods now take, so a |
| 12 | // member's standing can be re-derived on every request (spec §15.12). |
| 13 | // Nothing in these tests cancels it: the standing they exercise comes |
| 14 | // from testHub's StandingFor, not from the context. |
| 15 | var testCtx = context.Background() |
| 16 | |
| 17 | func testHub(now *time.Time) *Hub { |
| 18 | return New(Config{ |
| 19 | Now: func() time.Time { return *now }, |
| 20 | Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 21 | StandingFor: func(ctx context.Context, room string, personID int64) (Kind, bool, error) { |
| 22 | if personID < 100 { |
| 23 | return KindMember, true, nil |
| 24 | } |
| 25 | return KindGuest, true, nil |
| 26 | }, |
| 27 | }) |
| 28 | } |
| 29 | |
| 30 | func TestOpenIssuesADistinctPeerBoundToTheSession(t *testing.T) { |
| 31 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 32 | h := testHub(&now) |
| 33 | a, err := h.Open("r1", "sessA", 1, "Ann", KindMember) |
| 34 | if err != nil { |
| 35 | t.Fatal(err) |
| 36 | } |
| 37 | b, _ := h.Open("r1", "sessB", 2, "Bob", KindMember) |
| 38 | if a.Peer == b.Peer || len(a.Peer) != 32 { |
| 39 | t.Fatalf("peer ids %q %q", a.Peer, b.Peer) |
| 40 | } |
| 41 | if _, ok := h.Find("r1", "sessB", a.Peer); ok { |
| 42 | t.Fatal("a peer was found through another session") |
| 43 | } |
| 44 | if _, ok := h.Find("r2", "sessA", a.Peer); ok { |
| 45 | t.Fatal("a peer was found through another room") |
| 46 | } |
| 47 | if p, ok := h.Find("r1", "sessA", a.Peer); !ok || p.Admitted { |
| 48 | t.Fatal("fresh participation should exist and be unjoined") |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func TestOneLivePeerPerSessionPerRoom(t *testing.T) { |
| 53 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 54 | h := testHub(&now) |
| 55 | a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) |
| 56 | _, err := h.Open("r1", "sessA", 1, "Ann", KindMember) |
| 57 | if err != ErrSessionHasPeer { |
| 58 | t.Fatalf("second stream: %v", err) |
| 59 | } |
| 60 | h.Detach("r1", a.Peer, nil) |
| 61 | if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != ErrSessionHasPeer { |
| 62 | t.Fatal("a detached peer inside its resume window should still block a fresh stream") |
| 63 | } |
| 64 | now = now.Add(31 * time.Second) |
| 65 | h.Tick(context.Background()) |
| 66 | if _, err := h.Open("r1", "sessA", 1, "Ann", KindMember); err != nil { |
| 67 | t.Fatalf("after the window: %v", err) |
| 68 | } |
| 69 | } |
| 70 | |
| 71 | func TestSixteenParticipationsPerRoomThenRefuse(t *testing.T) { |
| 72 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 73 | h := testHub(&now) |
| 74 | for i := 0; i < 16; i++ { |
| 75 | if _, err := h.Open("r1", "s"+string(rune('a'+i)), int64(100+i), "g", KindGuest); err != nil { |
| 76 | t.Fatalf("open %d: %v", i, err) |
| 77 | } |
| 78 | } |
| 79 | if _, err := h.Open("r1", "sZ", 200, "g", KindGuest); err != ErrRoomFull { |
| 80 | t.Fatalf("seventeenth: %v", err) |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestUnjoinedParticipationDropsAfterThirtySeconds(t *testing.T) { |
| 85 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 86 | h := testHub(&now) |
| 87 | a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) |
| 88 | now = now.Add(31 * time.Second) |
| 89 | h.Tick(context.Background()) |
| 90 | if _, ok := h.Find("r1", "sessA", a.Peer); ok { |
| 91 | t.Fatal("unjoined participation survived the window") |
| 92 | } |
| 93 | if !a.Sub.Lost() { |
| 94 | t.Fatal("its subscriber was not closed") |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | func TestFourStreamsPerPerson(t *testing.T) { |
| 99 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 100 | h := testHub(&now) |
| 101 | for i := 0; i < 4; i++ { |
| 102 | if _, err := h.Open("r"+string(rune('1'+i)), "s"+string(rune('1'+i)), 1, "Ann", KindMember); err != nil { |
| 103 | t.Fatal(err) |
| 104 | } |
| 105 | } |
| 106 | if _, err := h.Open("r9", "s9", 1, "Ann", KindMember); err != ErrPersonStreams { |
| 107 | t.Fatalf("fifth stream: %v", err) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func TestSlowSubscriberIsDeclaredLost(t *testing.T) { |
| 112 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 113 | h := testHub(&now) |
| 114 | a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) |
| 115 | for i := 0; i < 70; i++ { |
| 116 | h.deliver("r1", a.Peer, Message{Type: "peer"}) |
| 117 | } |
| 118 | if !a.Sub.Lost() { |
| 119 | t.Fatal("65th undelivered event did not declare the subscriber lost") |
| 120 | } |
| 121 | var last Message |
| 122 | for m := range a.Sub.C() { |
| 123 | last = m |
| 124 | } |
| 125 | if last.Type != "lost" || last.Reason != "buffer" { |
| 126 | t.Fatalf("last event %+v, want lost/buffer", last) |
| 127 | } |
| 128 | } |
| 129 | |
| 130 | func TestTerminateDoesNotEvictWhenNotFull(t *testing.T) { |
| 131 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 132 | h := testHub(&now) |
| 133 | a, _ := h.Open("r1", "sessA", 1, "Ann", KindMember) |
| 134 | h.deliver("r1", a.Peer, Message{Type: TypeGone, From: "someone"}) |
| 135 | a.Sub.terminate(Message{Type: TypeLost, Reason: ReasonRemoved}) |
| 136 | |
| 137 | var got []Message |
| 138 | for m := range a.Sub.C() { |
| 139 | got = append(got, m) |
| 140 | } |
| 141 | if len(got) != 2 { |
| 142 | t.Fatalf("got %d events %+v, want the queued one and the lost", len(got), got) |
| 143 | } |
| 144 | if got[0].Type != TypeGone || got[0].From != "someone" { |
| 145 | t.Fatalf("first event %+v, want the queued gone", got[0]) |
| 146 | } |
| 147 | if got[1].Type != TypeLost || got[1].Reason != ReasonRemoved { |
| 148 | t.Fatalf("second event %+v, want lost/removed", got[1]) |
| 149 | } |
| 150 | } |
| 151 | |
| 152 | // A second stream that resumes an attached participation displaces the |
| 153 | // first (lost{replaced}, ruling above). The displaced handler then does |
| 154 | // what every stream handler does on its way out — Detach — and that |
| 155 | // must not take the replacement's attachment down with it. |
| 156 | func TestDetachOfADisplacedStreamLeavesTheReplacementAttached(t *testing.T) { |
| 157 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 158 | h := testHub(&now) |
| 159 | a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) |
| 160 | res, err := h.Resume(testCtx, "r1", "sa", a.Peer, nil) |
| 161 | if err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | if ev := drain(a.Sub); len(ev) != 1 || ev[0].Reason != ReasonReplaced { |
| 165 | t.Fatalf("displaced stream got %+v", ev) |
| 166 | } |
| 167 | h.Detach("r1", a.Peer, a.Sub) // the displaced handler, on its way out |
| 168 | if res.Sub.Lost() { |
| 169 | t.Fatal("the replacement stream was closed by the displaced one's Detach") |
| 170 | } |
| 171 | p, ok := h.Find("r1", "sa", a.Peer) |
| 172 | if !ok || p.sub != res.Sub || !p.resumeUntil.IsZero() { |
| 173 | t.Fatalf("participation lost its attachment: %+v", p) |
| 174 | } |
| 175 | // The replacement's own Detach still detaches. |
| 176 | h.Detach("r1", a.Peer, res.Sub) |
| 177 | if !res.Sub.Lost() || !p.resumeUntil.Equal(now.Add(ResumeWindow)) { |
| 178 | t.Fatalf("the attached stream's Detach did nothing: %+v", p) |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | // PeerNameFor is Remove's own authority, read rather than acted on: it |
| 183 | // must answer a name in exactly the cases Remove would go on to act, |
| 184 | // and false everywhere else, so a confirm page can never name — or |
| 185 | // offer to remove — a peer the caller could not actually remove. |
| 186 | func TestPeerNameForIsExactlyRemovesAuthority(t *testing.T) { |
| 187 | now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) |
| 188 | h := testHub(&now) |
| 189 | ctx := context.Background() |
| 190 | |
| 191 | member, _ := h.Open("r1", "sMember", 1, "Ann", KindMember) |
| 192 | if err := h.Join(ctx, "r1", "sMember", member.Peer); err != nil { |
| 193 | t.Fatal(err) |
| 194 | } |
| 195 | target, _ := h.Open("r1", "sTarget", 2, "Bob", KindMember) |
| 196 | |
| 197 | if name, ok := h.PeerNameFor("r1", "sMember", target.Peer); !ok || name != "Bob" { |
| 198 | t.Fatalf("member actor + present target: %q, %v", name, ok) |
| 199 | } |
| 200 | |
| 201 | guest, _ := h.Open("r1", "sGuest", 100, "Cid", KindGuest) |
| 202 | if err := h.Join(ctx, "r1", "sGuest", guest.Peer); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | if p, ok := h.Find("r1", "sGuest", guest.Peer); !ok || p.Admitted { |
| 206 | t.Fatal("the guest should be waiting, not admitted, for this case to test anything") |
| 207 | } |
| 208 | if _, ok := h.PeerNameFor("r1", "sGuest", target.Peer); ok { |
| 209 | t.Fatal("a waiting guest is not admitted and must not name anyone") |
| 210 | } |
| 211 | |
| 212 | other, _ := h.Open("r2", "sOther", 3, "Deb", KindMember) |
| 213 | if err := h.Join(ctx, "r2", "sOther", other.Peer); err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | if _, ok := h.PeerNameFor("r1", "sMember", other.Peer); ok { |
| 217 | t.Fatal("a target in another room must not be named") |
| 218 | } |
| 219 | |
| 220 | if _, ok := h.PeerNameFor("r1", "sMember", member.Peer); ok { |
| 221 | t.Fatal("the actor is not its own target") |
| 222 | } |
| 223 | |
| 224 | if _, ok := h.PeerNameFor("r1", "sNobody", target.Peer); ok { |
| 225 | t.Fatal("a session with no participation in this room must not name anyone") |
| 226 | } |
| 227 | } |
| 228 | |