Files
OwnCord/Server/ws/hub_test.go
T
J3vbandClaude Fable 5 fb5b058ee4 test(b3-6): seeded hub simulation with a six-part FIFO/seq oracle, fault-injected transport, exact seed replay (Tier 3b/3c) (#1458)
* test(b3-6): seeded hub simulation and fault-injected transport (items 2 and 3)

Server/ws/hub_sim_test.go drives a PCG-seeded interleaving of subscribe,
broadcast (global, channel, recipients-scoped, sequenced DM), ack, disconnect
and reconnect-transfer over a real Hub with eight headless clients, and a
model client checks the per-client FIFO/seq oracle from Server/CLAUDE.md
after every step: strictly increasing seq per connection, exact audience
delivery (nothing lost, extra or twice), a resume replayed exactly from the
watermark to the seq at which registerNow ran, h.seq advancing only for a
frame that reached the ring, an evicted watermark refused a replay, and a
replaced socket's late teardown reporting replaced=true. The resume step runs
reconnectRegister as-is (snapshot and registerNow under one seqMu section)
on a goroutine while up to three broadcasts race it; the model recovers the
snapshot point from the replay burst, so any interleaving is checkable.

OWNCORD_SIM_SEED replays one seed, OWNCORD_SIM_SEEDS (default 20) and
OWNCORD_SIM_STEPS (default 200) size a run, and a failure prints the seed,
the step, a ready-to-paste replay line and the last steps. The default runs
in about 2.3 s under -race; `make sim` runs 10,000 steps per seed.

Server/ws/faultconn_test.go is the seeded, deterministic frame transport the
simulation reads through: drop, tail cut, duplicate, bounded reorder and an
order-preserving lag from its own PCG stream, exported to ws_test through
export_test.go as NewFaultConnForTest. The simulation's default wire is a lag
plus tail cuts, the one fault a TCP-backed WebSocket really has; the silent
drop is the negative control that proves the oracle notices a lost replay.

BenchmarkReconnectStorm resumes 50 live clients per op through the same
path. newTestHub and its three seed helpers take testing.TB so the benchmark
can share them. No production code changes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* docs(b3-6): evidence block for items 2 and 3 (hub simulation, fault transport)

Oracle, the RED/GREEN excerpts (inverted assertion, seed replay, drop-all
wire, unsynchronized registerNow), wall-clock and benchmark figures, gate
results and the epoch-harness decision, under B3-6 in the plan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* fix(b3-6): hub sim — deterministic topic limiter for exact replay, a floor on the step mix, auth-frame-wins under transfer, wire-seed mixing

Review fixes for items 2 and 3.

Exact replay. TopicRateLimiter keys its window on time.Now(), so at 10,000
steps the shed boundary was a timing-dependent step and every later seq
differed between runs; the printed OWNCORD_SIM_SEED line could not reproduce
a failure. FreezeTopicLimiterForTest (export_test.go) swaps the hub's limiter
for one whose window never rolls over inside a run, so the shed is a
per-channel count. Three more leaks of the scheduler's interleaving into the
trajectory surfaced once that was fixed, and are closed the same way — by
taking the decision away from the race or making both outcomes read the
same: racing frames are pulled into the wire at attach time (queue fill no
longer depends on which side of the snapshot they fell), the racing burst is
aimed at the resuming client's own audience (a replay-superset frame was
read iff it landed before the snapshot), and a resume within the burst's
reach of the ring's eviction boundary is not raced (the allocations could
evict the watermark before or after the snapshot and pick replay or
fallback). Three runs of one seed now print byte-identical stats; what still
varies — how many racing seqs land in the replay burst — is printed on its
own line and stated in the doc comment.

Floor. TestHubSimulation aggregates the per-seed stats and requires every
load-bearing transition (the four broadcast kinds, resume, fallback, fresh,
cut, kicked, racing-in-replay) at least once across the default run, so a
constant change cannot turn the simulation into no-ops with CI green. Its
first run found that the overflow kick had become unreachable at 200 steps;
the sim's queue is 12 now (production stays 256).

Also: the resume step draws active_channel_id as none / the open channel /
another channel whether or not the old socket is registered, so registerNow's
auth-frame-wins branch runs under the transfer; the wire's PCG takes the seed
and (idx<<32|conns) as its two words instead of an arithmetic mix that
collided past 131 connections; seedTestUser takes testing.TB like its
siblings; the evidence block lists what the simulation does not cover and the
-timeout 60m the ten-pass deadlock gate needs, with the same line under
Traps carried forward.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — floor on raced resumes instead of the scheduler-decided key; log when the floor is skipped

racing-in-replay was the one floor key the scheduler decides, so a correct hub could in principle fail the floor on a run where no racing seq landed inside a burst. The floor now keys on raced resumes — a resume that got a replay while a burst ran (burst > 0 && ok), which the seed determines — and racing-in-replay stays a printed count. The floor also says so when it is skipped for OWNCORD_SIM_SEED or a shorter seed list instead of returning silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

* test(b3-6): hub sim — raced counts a broadcast allocated while registration was in progress, not the requested burst

Codex P2 on #1458: raced incremented on burst > 0, which counted a resume whose goroutine had returned before the first broadcast ran and one whose every channel broadcast the limiter shed, so the floor could pass with no broadcast overlapping a registration. raced now counts a resume where a racing broadcast allocated a seq while the reconnect goroutine had not yet been observed to return (the driver's done handshake, checked after each allocation). That is the scheduler's call, so raced moves off the deterministic stats line and is floored only in aggregate across the 20 default seeds — 179 bursts per run, 178–179 observed overlapping in three measured runs, odds named in the comment and the evidence block. The requested burst stays a printed, seed-determined count (bursts) and is floored as before; the floor logs its totals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01KmiqjgTuov1stBTB6uGkvo

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 14:57:34 +00:00

1469 lines
47 KiB
Go

package ws_test
import (
"context"
"encoding/json"
"fmt"
"slices"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/J3vb/OwnCord/Server/auth"
"github.com/J3vb/OwnCord/Server/db"
"github.com/J3vb/OwnCord/Server/ws"
)
// ─── test helpers ─────────────────────────────────────────────────────────────
func openTestDB(t testing.TB) *db.DB {
t.Helper()
database, err := db.Open(":memory:")
if err != nil {
t.Fatalf("db.Open: %v", err)
}
t.Cleanup(func() { _ = database.Close() })
migrFS := fstest.MapFS{
"001_schema.sql": {Data: hubTestSchema},
}
if err := db.MigrateFS(database, migrFS); err != nil {
t.Fatalf("MigrateFS: %v", err)
}
return database
}
func newTestHub(t testing.TB) (*ws.Hub, *db.DB) {
t.Helper()
database := openTestDB(t)
limiter := auth.NewRateLimiter()
hub := ws.NewHub(database, limiter, nil)
return hub, database
}
// seedTestUser inserts a Member-role user and returns its ID.
func seedTestUser(t testing.TB, database *db.DB, username string) int64 {
t.Helper()
id, err := database.CreateUser(context.Background(), username, "hash", 4)
if err != nil {
t.Fatalf("seedUser: %v", err)
}
return id
}
// seedOwnerUser inserts an Owner-role user and returns the full *db.User.
// Owner role (id=1) has all permissions (0x7FFFFFFF), so it passes all checks.
func seedOwnerUser(t testing.TB, database *db.DB, username string) *db.User {
t.Helper()
_, err := database.CreateUser(context.Background(), username, "hash", 1) // roleID=1 → Owner
if err != nil {
t.Fatalf("seedOwnerUser: %v", err)
}
user, err := database.GetUserByUsername(context.Background(), username)
if err != nil || user == nil {
t.Fatalf("seedOwnerUser GetUserByUsername: %v", err)
}
return user
}
// seedTestChannel inserts a channel and returns its ID.
func seedTestChannel(t testing.TB, database *db.DB, name string) int64 {
t.Helper()
id, err := database.CreateChannel(context.Background(), name, "text", "", "", 0)
if err != nil {
t.Fatalf("seedChannel: %v", err)
}
return id
}
// ─── Hub lifecycle ────────────────────────────────────────────────────────────
func TestNewHub_NotNil(t *testing.T) {
hub, _ := newTestHub(t)
if hub == nil {
t.Fatal("NewHub returned nil")
}
}
func TestHub_RunStops(t *testing.T) {
hub, _ := newTestHub(t)
done := make(chan struct{})
go func() {
hub.Run()
close(done)
}()
// Wait for the Run loop to start, then stop the hub.
waitFor(t, waitTimeout, hub.RunningForTest, "hub Run loop to start")
hub.Stop()
select {
case <-done:
// ok
case <-time.After(2 * time.Second):
t.Error("hub.Run() did not stop after hub.Stop()")
}
}
// ─── Register / Unregister ────────────────────────────────────────────────────
func TestHub_RegisterIncrementsCount(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
userID := seedTestUser(t, database, "alice")
send := make(chan []byte, 4)
hub.Register(ws.NewTestClient(hub, userID, send))
waitClientCount(t, hub, 1)
if hub.ClientCount() != 1 {
t.Errorf("ClientCount = %d, want 1", hub.ClientCount())
}
}
func TestHub_UnregisterDecrementsCount(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
userID := seedTestUser(t, database, "bob")
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, userID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.Unregister(c)
waitClientCount(t, hub, 0)
if hub.ClientCount() != 0 {
t.Errorf("ClientCount = %d, want 0", hub.ClientCount())
}
}
func TestHub_RegisterSameUserTwice(t *testing.T) {
// Second registration for same userID should replace the first.
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
userID := seedTestUser(t, database, "carol")
send1 := make(chan []byte, 4)
send2 := make(chan []byte, 4)
c2 := ws.NewTestClient(hub, userID, send2)
hub.Register(ws.NewTestClient(hub, userID, send1))
hub.Register(c2)
// Client events are processed in order: once c2 is visible, the first
// registration has been replaced.
waitRegistered(t, hub, c2)
if hub.ClientCount() != 1 {
t.Errorf("ClientCount = %d after double register, want 1", hub.ClientCount())
}
}
// ─── BroadcastToAll ───────────────────────────────────────────────────────────
func TestHub_BroadcastToAll_DeliversToAllClients(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
u1 := seedTestUser(t, database, "dave")
u2 := seedTestUser(t, database, "eve")
s1 := make(chan []byte, 4)
s2 := make(chan []byte, 4)
c2 := ws.NewTestClient(hub, u2, s2)
hub.Register(ws.NewTestClient(hub, u1, s1))
hub.Register(c2)
waitRegistered(t, hub, c2) // in-order events: both clients registered
msg := []byte(`{"type":"presence","payload":{}}`)
hub.BroadcastToAll(msg)
assertReceived(t, s1, msg, "client 1")
assertReceived(t, s2, msg, "client 2")
}
func TestHub_BroadcastToAll_NoClients(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
defer hub.Stop()
// Should not panic.
hub.BroadcastToAll([]byte(`{}`))
}
// ─── BroadcastToChannel ───────────────────────────────────────────────────────
func TestHub_BroadcastToChannel_OnlySendsToChannelMembers(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "general")
u1 := seedTestUser(t, database, "frank")
u2 := seedTestUser(t, database, "grace")
s1 := make(chan []byte, 4)
s2 := make(chan []byte, 4)
c1 := ws.NewTestClientWithChannel(hub, u1, chID, s1)
c2 := ws.NewTestClientWithChannel(hub, u2, 999, s2) // different channel
hub.Register(c1)
hub.Register(c2)
waitRegistered(t, hub, c2)
msg := []byte(`{"type":"chat_message","payload":{}}`)
hub.BroadcastToChannel(chID, msg)
assertReceived(t, s1, msg, "channel member")
assertNotReceived(t, s2, "non-member")
}
func TestHub_BroadcastToChannel_ZeroChannelSendsToAll(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
u1 := seedTestUser(t, database, "henry")
s1 := make(chan []byte, 4)
c1 := ws.NewTestClient(hub, u1, s1)
hub.Register(c1)
waitRegistered(t, hub, c1)
msg := []byte(`{"type":"presence","payload":{}}`)
hub.BroadcastToChannel(0, msg)
assertReceived(t, s1, msg, "client")
}
// ─── BroadcastChatBulkDeleted ─────────────────────────────────────────────────
// One chat_bulk_deleted carrying every purged id reaches the channel's
// subscribers, and nobody else — a purge must not fan out N chat_deleted events
// nor disclose ids to a client focused elsewhere.
func TestHub_BroadcastChatBulkDeleted_OneEventToChannelMembers(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "purged")
u1 := seedTestUser(t, database, "bulk1")
u2 := seedTestUser(t, database, "bulk2")
s1 := make(chan []byte, 4)
s2 := make(chan []byte, 4)
hub.Register(ws.NewTestClientWithChannel(hub, u1, chID, s1))
c2 := ws.NewTestClientWithChannel(hub, u2, 999, s2)
hub.Register(c2)
waitRegistered(t, hub, c2)
hub.BroadcastChatBulkDeleted(chID, []int64{7, 6, 5})
select {
case got := <-s1:
var env struct {
Type string `json:"type"`
Payload struct {
ChannelID int64 `json:"channel_id"`
IDs []int64 `json:"ids"`
} `json:"payload"`
}
if err := json.Unmarshal(got, &env); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if env.Type != "chat_bulk_deleted" {
t.Errorf("type = %q, want chat_bulk_deleted", env.Type)
}
if env.Payload.ChannelID != chID {
t.Errorf("channel_id = %d, want %d", env.Payload.ChannelID, chID)
}
if !slices.Equal(env.Payload.IDs, []int64{7, 6, 5}) {
t.Errorf("ids = %v, want [7 6 5]", env.Payload.IDs)
}
case <-time.After(500 * time.Millisecond):
t.Fatal("channel member did not receive chat_bulk_deleted")
}
assertNotReceived(t, s1, "channel member (second event)")
assertNotReceived(t, s2, "client focused on another channel")
}
// ─── BUG-122: Unfocused client must NOT receive channel-scoped broadcasts ────
func TestHub_BroadcastToChannel_SkipsUnfocusedClient(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "secret")
u1 := seedTestUser(t, database, "focused")
u2 := seedTestUser(t, database, "unfocused")
s1 := make(chan []byte, 4)
s2 := make(chan []byte, 4)
c1 := ws.NewTestClientWithChannel(hub, u1, chID, s1) // focused on channel
c2 := ws.NewTestClient(hub, u2, s2) // channelID == 0 (unfocused)
hub.Register(c1)
hub.Register(c2)
waitRegistered(t, hub, c2)
msg := []byte(`{"type":"chat_message","payload":{"content":"secret"}}`)
hub.BroadcastToChannel(chID, msg)
assertReceived(t, s1, msg, "focused client")
assertNotReceived(t, s2, "unfocused client must NOT receive channel broadcast")
}
// Voice membership is gated on CONNECT_VOICE only, so it must never on its own
// subscribe a client to a channel's message stream — that route requires
// READ_MESSAGES (channel_focus). Registration without a READ_MESSAGES set must
// therefore deliver nothing.
func TestHub_BroadcastToChannel_NotDeliveredOnVoiceMembershipAlone(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "voice-text")
u1 := seedTestUser(t, database, "voiceuser")
s1 := make(chan []byte, 4)
c1 := ws.NewTestClient(hub, u1, s1) // channelID == 0
ws.SetClientVoiceChID(c1, chID) // but in voice on this channel
hub.Register(c1)
waitRegistered(t, hub, c1)
msg := []byte(`{"type":"chat_message","payload":{"content":"hello"}}`)
hub.BroadcastToChannel(chID, msg)
assertNotReceived(t, s1, "voice membership alone must NOT deliver the channel message stream")
}
// The inherited voice-channel subscription follows the handshake's
// READ_MESSAGES set: a reconnecting client that may read the channel keeps live
// delivery (it never re-sends channel_focus), one that may not gets nothing.
func TestHub_RegisterNow_VoiceChannelSubscriptionFollowsReadPermission(t *testing.T) {
tests := []struct {
name string
slug string
readable func(chID int64) map[int64]bool
want bool
}{
{
name: "READ_MESSAGES on the voice channel keeps the stream",
slug: "readable",
readable: func(chID int64) map[int64]bool { return map[int64]bool{chID: true} },
want: true,
},
{
name: "READ_MESSAGES only elsewhere denies the stream",
slug: "unreadable",
readable: func(chID int64) map[int64]bool { return map[int64]bool{chID + 1000: true} },
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "voice-text-"+tc.slug)
u1 := seedTestUser(t, database, "voiceuser-"+tc.slug)
s1 := make(chan []byte, 4)
c1 := ws.NewTestClient(hub, u1, s1) // channelID == 0 (no channel_focus yet)
ws.SetClientVoiceChID(c1, chID)
hub.RegisterNowWithReadableForTest(c1, tc.readable(chID))
msg := []byte(`{"type":"chat_message","payload":{"content":"hello"}}`)
hub.BroadcastToChannel(chID, msg)
if tc.want {
assertReceived(t, s1, msg, "voice client with READ_MESSAGES")
} else {
assertNotReceived(t, s1, "voice client without READ_MESSAGES")
}
})
}
}
func TestHub_BroadcastToAll_StillDeliversToUnfocusedClient(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
u1 := seedTestUser(t, database, "globaluser")
s1 := make(chan []byte, 4)
c1 := ws.NewTestClient(hub, u1, s1) // channelID == 0 (unfocused)
hub.Register(c1)
waitRegistered(t, hub, c1)
msg := []byte(`{"type":"presence","payload":{"status":"online"}}`)
hub.BroadcastToAll(msg)
assertReceived(t, s1, msg, "unfocused client must still receive global broadcasts")
}
func TestHub_BroadcastToChannel_UnfocusedDoesNotReceiveAnyChannel(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
ch1 := seedTestChannel(t, database, "chan1")
ch2 := seedTestChannel(t, database, "chan2")
u1 := seedTestUser(t, database, "snooper")
s1 := make(chan []byte, 8)
c1 := ws.NewTestClient(hub, u1, s1) // unfocused
hub.Register(c1)
waitRegistered(t, hub, c1)
msg1 := []byte(`{"type":"chat_message","payload":{"channel":"1"}}`)
msg2 := []byte(`{"type":"chat_message","payload":{"channel":"2"}}`)
hub.BroadcastToChannel(ch1, msg1)
hub.BroadcastToChannel(ch2, msg2)
assertNotReceived(t, s1, "unfocused client must NOT receive ch1 broadcast")
}
// ─── SendToUser ───────────────────────────────────────────────────────────────
func TestHub_SendToUser_ExistingClient(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
userID := seedTestUser(t, database, "ivan")
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, userID, send)
hub.Register(c)
waitRegistered(t, hub, c)
msg := []byte(`{"type":"chat_send_ok","payload":{}}`)
ok := hub.SendToUser(userID, msg)
if !ok {
t.Error("SendToUser returned false for existing client")
}
assertReceived(t, send, msg, "target user")
}
func TestHub_SendToUser_MissingClient(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
defer hub.Stop()
ok := hub.SendToUser(9999, []byte(`{}`))
if ok {
t.Error("SendToUser should return false for absent client")
}
}
// ─── Message dispatch ─────────────────────────────────────────────────────────
func TestHub_HandleMessage_UnknownType_SendsError(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
userID := seedTestUser(t, database, "julia")
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, userID, send)
hub.Register(c)
waitRegistered(t, hub, c)
raw := []byte(`{"type":"totally_unknown","payload":{}}`)
hub.HandleMessageForTest(c, raw)
select {
case got := <-send:
var resp map[string]any
if err := json.Unmarshal(got, &resp); err != nil {
t.Fatalf("unmarshal response: %v", err)
}
if resp["type"] != "error" {
t.Errorf("type = %q, want 'error'", resp["type"])
}
case <-time.After(500 * time.Millisecond):
t.Error("expected error response for unknown message type")
}
}
func TestHub_HandleMessage_InvalidJSON(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
userID := seedTestUser(t, database, "kim")
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, userID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.HandleMessageForTest(c, []byte(`NOT JSON`))
select {
case got := <-send:
var resp map[string]any
if err := json.Unmarshal(got, &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if resp["type"] != "error" {
t.Errorf("type = %q, want 'error'", resp["type"])
}
case <-time.After(500 * time.Millisecond):
t.Error("expected error response for invalid JSON")
}
}
// ─── Rate limiting ────────────────────────────────────────────────────────────
func TestHub_ChatSend_RateLimit(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
user := seedOwnerUser(t, database, "larry")
chID := seedTestChannel(t, database, "rl-test")
send := make(chan []byte, 64)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
waitRegistered(t, hub, c)
payload := map[string]any{
"channel_id": chID,
"content": "hi",
}
raw, _ := json.Marshal(map[string]any{
"type": "chat_send",
"payload": payload,
})
// Send 12 messages rapidly — 11th and beyond should be rate-limited.
for range 12 {
hub.HandleMessageForTest(c, raw)
}
// Drain all messages, count errors — error replies are sent synchronously
// by handleMessage, so they are already buffered on the send channel.
errCount := 0
drainLoop:
for {
select {
case got := <-send:
var resp map[string]any
if err := json.Unmarshal(got, &resp); err == nil {
if resp["type"] == "error" {
errCount++
}
}
default:
break drainLoop
}
}
if errCount == 0 {
t.Error("expected at least one rate-limit error response")
}
}
// ─── Concurrency ─────────────────────────────────────────────────────────────
func TestHub_ConcurrentRegisterUnregister(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
var wg sync.WaitGroup
for i := range 20 {
wg.Add(1)
go func(i int) {
defer wg.Done()
username := fmt.Sprintf("user%d", i)
userID := seedTestUser(t, database, username)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, userID, send)
hub.Register(c)
waitRegistered(t, hub, c)
hub.Unregister(c)
}(i)
}
wg.Wait()
// The hub loop drains register/unregister asynchronously; poll instead of
// a fixed sleep, which flakes under -race on slow runners.
waitFor(t, 5*time.Second, func() bool { return hub.ClientCount() == 0 }, "churned clients to unregister")
if hub.ClientCount() != 0 {
t.Errorf("expected 0 clients after concurrent churn, got %d", hub.ClientCount())
}
}
// ─── GetClient ───────────────────────────────────────────────────────────────
func TestHub_GetClient(t *testing.T) {
hub, _ := newTestHub(t)
send := make(chan []byte, 256)
client := ws.NewTestClient(hub, 42, send)
hub.Register(client)
go hub.Run()
defer hub.Stop()
waitRegistered(t, hub, client)
got := hub.GetClient(42)
if got == nil {
t.Fatal("GetClient(42) returned nil")
}
got2 := hub.GetClient(999)
if got2 != nil {
t.Fatal("GetClient(999) should return nil")
}
}
// ─── assertion helpers ────────────────────────────────────────────────────────
// assertReceived checks that a message was received and contains the same JSON
// fields as want (ignoring the "seq" field injected by broadcast delivery).
func assertReceived(t *testing.T, ch <-chan []byte, want []byte, label string) {
t.Helper()
select {
case got := <-ch:
var gotMap map[string]json.RawMessage
if err := json.Unmarshal(got, &gotMap); err != nil {
t.Errorf("%s: unmarshal got: %v", label, err)
return
}
var wantMap map[string]json.RawMessage
if err := json.Unmarshal(want, &wantMap); err != nil {
t.Errorf("%s: unmarshal want: %v", label, err)
return
}
// Strip seq before comparing — broadcasts have it, direct sends don't.
delete(gotMap, "seq")
for k, wv := range wantMap {
gv, ok := gotMap[k]
if !ok {
t.Errorf("%s: missing key %q in received message", label, k)
continue
}
if string(gv) != string(wv) {
t.Errorf("%s: key %q: got %s, want %s", label, k, gv, wv)
}
}
case <-time.After(500 * time.Millisecond):
t.Errorf("%s: did not receive expected message within timeout", label)
}
}
func assertNotReceived(t *testing.T, ch <-chan []byte, label string) {
t.Helper()
select {
case got := <-ch:
t.Errorf("%s: received unexpected message: %q", label, got)
case <-time.After(100 * time.Millisecond):
// ok — nothing received
}
}
// ─── LiveKit lifecycle ────────────────────────────────────────────────────────
func TestHub_SetLiveKit_NilSafe(t *testing.T) {
hub, _ := newTestHub(t)
// Setting a nil LiveKit client must not panic.
hub.SetLiveKit(nil)
}
// ─── GracefulStop ─────────────────────────────────────────────────────────────
func TestHub_GracefulStop_StopsHub(t *testing.T) {
hub, _ := newTestHub(t)
done := make(chan struct{})
go func() {
hub.Run()
close(done)
}()
waitFor(t, waitTimeout, hub.RunningForTest, "hub Run loop to start")
hub.GracefulStop()
select {
case <-done:
// ok — hub stopped
case <-time.After(2 * time.Second):
t.Error("hub.Run() did not stop after GracefulStop()")
}
}
func TestHub_GracefulStop_NoPanic(t *testing.T) {
hub, _ := newTestHub(t)
go hub.Run()
// Must not panic with no LiveKit process.
hub.GracefulStop()
}
func TestHub_GracefulStop_Idempotent(t *testing.T) {
// BUG-087: GracefulStop must be safe to call concurrently/twice.
// Without sync.Once protection, double lkProcess.Stop() can panic.
hub, _ := newTestHub(t)
go hub.Run()
var wg sync.WaitGroup
wg.Add(2)
for range 2 {
go func() {
defer wg.Done()
hub.GracefulStop()
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
// Success: no panic from concurrent GracefulStop.
case <-time.After(15 * time.Second):
t.Fatal("concurrent GracefulStop calls deadlocked")
}
}
// ─── CleanupVoiceForChannel ───────────────────────────────────────────────────
func TestHub_CleanupVoiceForChannel_NoVoiceState_NoPanic(t *testing.T) {
hub, _ := newTestHub(t)
// Must not panic when channel has no voice state in DB.
hub.CleanupVoiceForChannel(9999)
}
// TestHub_Register_CleansUpOldVoiceState was removed because duplicate
// logins are now rejected at the WebSocket handshake level (commit 00bbb46)
// before hub.Register is called. The hub's register case simply overwrites
// the client map entry; voice cleanup for disconnects is handled by
// handleVoiceLeave called from readPump/ICE monitor.
// ─── sweepStaleClients ──────────────────────────────────────────────────────
func TestHub_SweepStaleClients_RemovesInactiveClients(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
u1 := seedTestUser(t, database, "stale-alice")
u2 := seedTestUser(t, database, "fresh-bob")
s1 := make(chan []byte, 4)
s2 := make(chan []byte, 4)
c1 := ws.NewTestClient(hub, u1, s1)
c2 := ws.NewTestClient(hub, u2, s2)
hub.Register(c1)
hub.Register(c2)
waitRegistered(t, hub, c2)
ws.SetClientLastActivityForTest(c1, time.Now().Add(-2*time.Minute))
ws.SetClientLastActivityForTest(c2, time.Now())
// sweepStaleClients kicks synchronously (kickClient) — no wait needed.
hub.SweepStaleClientsForTest()
if hub.ClientCount() != 1 {
t.Errorf("ClientCount = %d after sweep, want 1", hub.ClientCount())
}
if hub.GetClient(u1) != nil {
t.Error("stale client should have been removed")
}
if hub.GetClient(u2) == nil {
t.Error("fresh client should still be present")
}
}
func TestHub_SweepStaleClients_NoClientsNoPanic(t *testing.T) {
hub, _ := newTestHub(t)
hub.SweepStaleClientsForTest()
}
func TestHub_SweepStaleClients_AllFresh(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
u1 := seedTestUser(t, database, "fresh-carol")
s1 := make(chan []byte, 4)
c1 := ws.NewTestClient(hub, u1, s1)
hub.Register(c1)
waitRegistered(t, hub, c1)
ws.SetClientLastActivityForTest(c1, time.Now())
// sweepStaleClients kicks synchronously — its effects are visible on return.
hub.SweepStaleClientsForTest()
if hub.ClientCount() != 1 {
t.Errorf("ClientCount = %d after sweep of fresh clients, want 1", hub.ClientCount())
}
}
// ─── Session sweep (BUG-109) ──────────────────────────────────────────────
// TestHub_SweepRevokedSessions_KicksRevokedClient verifies that the periodic
// session sweep disconnects clients whose sessions have been deleted from the
// database (e.g. after logout on another device).
func TestHub_SweepRevokedSessions_KicksRevokedClient(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
// Create two users with sessions.
uid1, err := database.CreateUser(context.Background(), "alice-revoke", "hash", 3)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
uid2, err := database.CreateUser(context.Background(), "bob-valid", "hash", 3)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
u1, _ := database.GetUserByID(context.Background(), uid1)
u2, _ := database.GetUserByID(context.Background(), uid2)
token1 := "revoke-token-1"
token2 := "valid-token-2"
hash1 := auth.HashToken(token1)
hash2 := auth.HashToken(token2)
if _, err := database.CreateSession(context.Background(), uid1, hash1, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession 1: %v", err)
}
if _, err := database.CreateSession(context.Background(), uid2, hash2, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession 2: %v", err)
}
s1 := make(chan []byte, 4)
s2 := make(chan []byte, 4)
c1 := ws.NewTestClientWithTokenHash(hub, u1, hash1, 0, s1)
c2 := ws.NewTestClientWithTokenHash(hub, u2, hash2, 0, s2)
hub.Register(c1)
hub.Register(c2)
waitRegistered(t, hub, c2)
// Delete alice's session (simulating logout from another device).
if err := database.DeleteSession(context.Background(), hash1); err != nil {
t.Fatalf("DeleteSession: %v", err)
}
// Run the session sweep — it kicks synchronously (kickClient).
hub.SweepRevokedSessionsForTest()
// Alice should be kicked, Bob should remain.
if hub.GetClient(uid1) != nil {
t.Error("revoked client alice should have been kicked")
}
if hub.GetClient(uid2) == nil {
t.Error("valid client bob should still be connected")
}
if hub.ClientCount() != 1 {
t.Errorf("ClientCount = %d, want 1", hub.ClientCount())
}
}
// TestHub_SweepRevokedSessions_KicksBannedClient verifies the sweep's batched
// session lookup carries ban status through: a client whose user was banned
// after connecting is disconnected even though its session row still exists.
func TestHub_SweepRevokedSessions_KicksBannedClient(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
uid, err := database.CreateUser(context.Background(), "soon-banned", "hash", 3)
if err != nil {
t.Fatalf("CreateUser: %v", err)
}
u, _ := database.GetUserByID(context.Background(), uid)
hash := auth.HashToken("ban-sweep-token")
if _, err := database.CreateSession(context.Background(), uid, hash, "test", "127.0.0.1"); err != nil {
t.Fatalf("CreateSession: %v", err)
}
s := make(chan []byte, 4)
c := ws.NewTestClientWithTokenHash(hub, u, hash, 0, s)
hub.Register(c)
waitRegistered(t, hub, c)
if err := database.BanUser(context.Background(), uid, "rule violation", nil); err != nil {
t.Fatalf("BanUser: %v", err)
}
hub.SweepRevokedSessionsForTest()
if hub.GetClient(uid) != nil {
t.Error("banned client should have been kicked by the session sweep")
}
}
// TestHub_SweepRevokedSessions_NoDBNoPanic verifies the sweep is a no-op
// when the hub has no database (nil-safe).
func TestHub_SweepRevokedSessions_NoDBNoPanic(t *testing.T) {
hub := ws.NewHubForTest()
hub.SweepRevokedSessionsForTest() // should not panic
}
// TestHub_SweepRevokedSessions_EmptyTokenHashSkipped verifies that clients
// without a token hash (e.g. test clients) are not kicked by the sweep.
func TestHub_SweepRevokedSessions_EmptyTokenHashSkipped(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
uid := seedTestUser(t, database, "no-hash-user")
s := make(chan []byte, 4)
c := ws.NewTestClient(hub, uid, s)
hub.Register(c)
waitRegistered(t, hub, c)
hub.SweepRevokedSessionsForTest()
if hub.ClientCount() != 1 {
t.Errorf("ClientCount = %d, want 1 (client without token hash should survive)", hub.ClientCount())
}
}
// ─── LiveKitHealthCheck ─────────────────────────────────────────────────────
func TestHub_LiveKitHealthCheck_NilReturnsError(t *testing.T) {
hub, _ := newTestHub(t)
ok, err := hub.LiveKitHealthCheck(context.Background())
if ok {
t.Error("expected ok=false when LiveKit is nil")
}
if err == nil {
t.Error("expected non-nil error when LiveKit is nil")
}
}
// ─── SetLiveKitProcess ──────────────────────────────────────────────────────
func TestHub_SetLiveKitProcess(t *testing.T) {
hub, _ := newTestHub(t)
hub.SetLiveKitProcess(nil)
go hub.Run()
hub.GracefulStop()
}
// ─── VoiceSessionCount ─────────────────────────────────────────────────────
func TestHub_VoiceSessionCount(t *testing.T) {
tests := []struct {
name string
voiceChs []int64
wantCount int
}{
{"no clients", nil, 0},
{"all in voice", []int64{100, 200, 300}, 3},
{"none in voice", []int64{0, 0}, 0},
{"mixed", []int64{100, 0, 200, 0}, 2},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
for i, vch := range tc.voiceChs {
username := fmt.Sprintf("voice-%s-%d", tc.name, i)
uid := seedTestUser(t, database, username)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, uid, send)
if vch != 0 {
ws.SetClientVoiceChID(c, vch)
}
hub.Register(c)
waitRegistered(t, hub, c)
}
got := hub.VoiceSessionCount()
if got != tc.wantCount {
t.Errorf("VoiceSessionCount() = %d, want %d", got, tc.wantCount)
}
})
}
}
// ─── RefreshChannelVisibility ─────────────────────────────────────────────────
// drainForMsgType reads messages from send until one with the given type
// arrives or the timeout expires. Returns the decoded payload-bearing message.
func drainForMsgType(t *testing.T, send chan []byte, msgType string) map[string]any {
t.Helper()
deadline := time.After(500 * time.Millisecond)
for {
select {
case raw := <-send:
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
if msg["type"] == msgType {
return msg
}
case <-deadline:
t.Fatalf("timed out waiting for %q message", msgType)
return nil
}
}
}
// assertNoMsgType asserts that no message of the given type is buffered.
func assertNoMsgType(t *testing.T, send chan []byte, msgType string) {
t.Helper()
for {
select {
case raw := <-send:
var msg map[string]any
if err := json.Unmarshal(raw, &msg); err != nil {
continue
}
if msg["type"] == msgType {
t.Fatalf("unexpected %q message", msgType)
}
default:
return
}
}
}
func TestRefreshChannelVisibility_TargetedSends(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "secret-room")
ch, err := database.GetChannel(context.Background(), chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: %v", err)
}
owner := seedOwnerUser(t, database, "vis-owner")
memberID := seedTestUser(t, database, "vis-member")
member, err := database.GetUserByID(context.Background(), memberID)
if err != nil || member == nil {
t.Fatalf("GetUserByID: %v", err)
}
ownerSend := make(chan []byte, 16)
memberSend := make(chan []byte, 16)
ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend)
memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend)
hub.Register(ownerClient)
hub.Register(memberClient)
waitRegistered(t, hub, memberClient)
// Hide the channel from the Member role (deny ReadMessages).
if _, err := database.ExecContext(context.Background(),
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`,
chID,
); err != nil {
t.Fatalf("insert override: %v", err)
}
hub.RefreshChannelVisibility(ch)
// Member loses the channel; owner (admin bit) keeps it.
drainForMsgType(t, memberSend, "channel_delete")
drainForMsgType(t, ownerSend, "channel_create")
// Restore visibility — the member gets the channel back.
if _, err := database.ExecContext(context.Background(),
`DELETE FROM channel_overrides WHERE channel_id = ? AND role_id = 4`, chID,
); err != nil {
t.Fatalf("delete override: %v", err)
}
hub.RefreshChannelVisibility(ch)
drainForMsgType(t, memberSend, "channel_create")
assertNoMsgType(t, memberSend, "channel_delete")
}
// can_send used to be computed only in the ready payload, so a mid-session
// permission edit left a connected client's composer on its stale connect-time
// verdict until the socket was rebuilt. The targeted channel_create this
// fan-out sends now carries each recipient's own verdict — and because it is
// per-recipient, two clients must be able to receive different answers from
// the same RefreshChannelVisibility call.
func TestRefreshChannelVisibility_TargetedCreateCarriesPerClientCanSend(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
chID := seedTestChannel(t, database, "cansend-room")
ch, err := database.GetChannel(context.Background(), chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: %v", err)
}
owner := seedOwnerUser(t, database, "cansend-owner")
memberID := seedTestUser(t, database, "cansend-member")
member, err := database.GetUserByID(context.Background(), memberID)
if err != nil || member == nil {
t.Fatalf("GetUserByID: %v", err)
}
ownerSend := make(chan []byte, 16)
memberSend := make(chan []byte, 16)
ownerClient := ws.NewTestClientWithUser(hub, owner, chID, ownerSend)
memberClient := ws.NewTestClientWithUser(hub, member, chID, memberSend)
hub.Register(ownerClient)
hub.Register(memberClient)
waitRegistered(t, hub, memberClient)
// Member keeps READ_MESSAGES (0x0002) but loses SEND_MESSAGES (0x0001), so
// the channel stays VISIBLE while posting is revoked — precisely the case a
// visibility-only fan-out used to leave with a stale composer.
if _, err := database.ExecContext(context.Background(),
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 1)`,
chID,
); err != nil {
t.Fatalf("insert override: %v", err)
}
hub.RefreshChannelVisibility(ch)
memberMsg := drainForMsgType(t, memberSend, "channel_create")
memberPayload, ok := memberMsg["payload"].(map[string]any)
if !ok {
t.Fatalf("member channel_create payload not an object: %#v", memberMsg["payload"])
}
canSend, present := memberPayload["can_send"]
if !present {
t.Fatal("targeted channel_create omitted can_send — the client keeps its stale connect-time verdict")
}
if canSend != false {
t.Fatalf("member can_send = %v, want false (SEND_MESSAGES denied)", canSend)
}
// Same call, same channel, different recipient: the owner's admin bit must
// still yield true, proving the value is resolved per client rather than
// encoded once for the whole audience.
ownerMsg := drainForMsgType(t, ownerSend, "channel_create")
ownerPayload, ok := ownerMsg["payload"].(map[string]any)
if !ok {
t.Fatalf("owner channel_create payload not an object: %#v", ownerMsg["payload"])
}
if ownerPayload["can_send"] != true {
t.Fatalf("owner can_send = %v, want true (admin bypass)", ownerPayload["can_send"])
}
}
func TestRefreshChannelVisibility_ForcesFullResyncForStaleResumes(t *testing.T) {
hub, database := newTestHub(t)
chID := seedTestChannel(t, database, "watermark-room")
ch, err := database.GetChannel(context.Background(), chID)
if err != nil || ch == nil {
t.Fatalf("GetChannel: %v", err)
}
// No visibility change yet — resume is allowed regardless of seq.
if hub.MustFullResyncForTest(1) {
t.Error("expected replay allowed before any visibility change")
}
hub.SeedSeq(41)
hub.RefreshChannelVisibility(ch)
// Clients resuming from at/before the change must take the full path.
if !hub.MustFullResyncForTest(41) {
t.Error("expected forced full resync for lastSeq at the watermark")
}
if !hub.MustFullResyncForTest(10) {
t.Error("expected forced full resync for lastSeq before the watermark")
}
// Clients that saw sequenced traffic after the change may replay.
if hub.MustFullResyncForTest(42) {
t.Error("expected replay allowed for lastSeq after the watermark")
}
}
// ─── BroadcastMemberUpdate (role reassignment) ────────────────────────────────
// A role change must drop the live channel topics the new role cannot READ —
// the subscription is created once, at channel_focus, and otherwise outlives
// the authorization it was granted under. DM topics are membership-gated, not
// role-gated, so they must survive.
func TestBroadcastMemberUpdate_RevokesUnreadableSubscriptions(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
ctx := context.Background()
chID := seedTestChannel(t, database, "role-room")
dmID, err := database.CreateChannel(ctx, "dm-room", "dm", "", "", 0)
if err != nil {
t.Fatalf("CreateChannel(dm): %v", err)
}
// Connect as Owner (reads everything) with the text channel focused.
user := seedOwnerUser(t, database, "demote-me")
if _, err := database.ExecContext(ctx,
`INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, dmID, user.ID,
); err != nil {
t.Fatalf("insert dm_participants: %v", err)
}
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
waitRegistered(t, hub, c)
// The user is a participant of this DM but has closed it (no dm_open_state
// row), so the topic is held while being absent from the allowed set.
hub.PubSubForTest().Subscribe(c, ws.ChannelTopic(dmID))
// Demote to Member, with READ_MESSAGES denied to that role on the channel.
if _, err := database.ExecContext(ctx,
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`, chID,
); err != nil {
t.Fatalf("insert override: %v", err)
}
if err := database.UpdateUserRole(ctx, user.ID, 4); err != nil {
t.Fatalf("UpdateUserRole: %v", err)
}
hub.SeedSeq(41)
hub.BroadcastMemberUpdate(user.ID, "member")
msg := drainForMsgType(t, send, "channel_delete")
payload, _ := msg["payload"].(map[string]any)
id, _ := payload["id"].(float64)
if int64(id) != chID {
t.Errorf("channel_delete id = %v, want %d", payload["id"], chID)
}
// The socket stays up; only the unreadable topic is gone.
if got := hub.ClientCount(); got != 1 {
t.Fatalf("ClientCount = %d, want 1 (socket must stay up)", got)
}
topics := hub.PubSubForTest().TopicsForClient(user.ID)
if slices.Contains(topics, ws.ChannelTopic(chID)) {
t.Error("still subscribed to the revoked channel topic")
}
if !slices.Contains(topics, ws.ChannelTopic(dmID)) {
t.Error("DM topic revoked by a role change (DM access is membership-gated)")
}
// The impact itself: channel traffic no longer reaches the demoted socket.
// Absence assertion: bounded window for a wrongly-delivered message to
// arrive before checking the buffer.
hub.BroadcastToChannel(chID, []byte(`{"type":"chat_message"}`))
time.Sleep(30 * time.Millisecond)
assertNoMsgType(t, send, "chat_message")
// A resume across the change must take the full-ready path.
if !hub.MustFullResyncForTest(41) {
t.Error("expected forced full resync for a resume at the change watermark")
}
}
// When the new visibility cannot be resolved (DB hiccup), the socket is closed
// rather than left half-revoked: the client reconnects and rebuilds from a
// ready payload computed with the new role.
func TestBroadcastMemberUpdate_ClosesSocketWhenVisibilityUnresolved(t *testing.T) {
hub, database := newTestHub(t)
go hub.Run()
defer hub.Stop()
ctx := context.Background()
chID := seedTestChannel(t, database, "hiccup-room")
uid := seedTestUser(t, database, "hiccup-user")
user, err := database.GetUserByID(ctx, uid)
if err != nil || user == nil {
t.Fatalf("GetUserByID: %v", err)
}
send := make(chan []byte, 16)
c := ws.NewTestClientWithUser(hub, user, chID, send)
hub.Register(c)
waitRegistered(t, hub, c)
// Break the override lookup computeAllowedChannels depends on.
if _, err := database.ExecContext(ctx, `DROP TABLE channel_overrides`); err != nil {
t.Fatalf("drop channel_overrides: %v", err)
}
hub.BroadcastMemberUpdate(uid, "member")
if got := hub.ClientCount(); got != 0 {
t.Errorf("ClientCount = %d, want 0 (socket must close when visibility is unresolved)", got)
}
if topics := hub.PubSubForTest().TopicsForClient(uid); len(topics) != 0 {
t.Errorf("TopicsForClient = %v, want none", topics)
}
}
// hubTestSchema is the minimal schema needed for hub tests.
var hubTestSchema = []byte(`
CREATE TABLE IF NOT EXISTS roles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT,
permissions INTEGER NOT NULL DEFAULT 0,
position INTEGER NOT NULL DEFAULT 0,
is_default INTEGER NOT NULL DEFAULT 0
);
INSERT OR IGNORE INTO roles (id, name, color, permissions, position, is_default) VALUES
(1, 'Owner', '#E74C3C', 2147483647, 100, 0),
(2, 'Admin', '#F39C12', 1073741823, 80, 0),
(3, 'Moderator', '#3498DB', 1048575, 60, 0),
(4, 'Member', NULL, 1635, 40, 1);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
password TEXT NOT NULL,
avatar TEXT,
role_id INTEGER NOT NULL DEFAULT 4 REFERENCES roles(id),
totp_secret TEXT,
status TEXT NOT NULL DEFAULT 'offline',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_seen TEXT,
banned INTEGER NOT NULL DEFAULT 0,
ban_reason TEXT,
ban_expires TEXT,
identity_public_key TEXT,
display_name TEXT,
about TEXT,
custom_status TEXT
);
CREATE TABLE IF NOT EXISTS sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
device TEXT,
ip_address TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_used TEXT NOT NULL DEFAULT (datetime('now')),
expires_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'text',
category TEXT,
topic TEXT,
position INTEGER NOT NULL DEFAULT 0,
slow_mode INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
voice_max_users INTEGER NOT NULL DEFAULT 0,
voice_quality TEXT,
mixing_threshold INTEGER,
voice_max_video INTEGER NOT NULL DEFAULT 0,
nsfw INTEGER NOT NULL DEFAULT 0,
is_group INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS channel_overrides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
allow INTEGER NOT NULL DEFAULT 0,
deny INTEGER NOT NULL DEFAULT 0,
UNIQUE(channel_id, role_id)
);
CREATE TABLE IF NOT EXISTS channel_user_overrides (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
allow INTEGER NOT NULL DEFAULT 0,
deny INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT NOT NULL,
reply_to INTEGER REFERENCES messages(id) ON DELETE SET NULL,
edited_at TEXT,
deleted INTEGER NOT NULL DEFAULT 0,
pinned INTEGER NOT NULL DEFAULT 0,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
mentions_everyone INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS message_mentions (
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
mentioned_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (message_id, mentioned_user_id)
);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
content,
content='messages',
content_rowid='id'
);
CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
END;
CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
INSERT INTO messages_fts(messages_fts, rowid, content) VALUES('delete', old.id, old.content);
INSERT INTO messages_fts(rowid, content) VALUES (new.id, new.content);
END;
CREATE TABLE IF NOT EXISTS reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
UNIQUE(message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS read_states (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
last_message_id INTEGER NOT NULL DEFAULT 0,
mention_count INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (user_id, channel_id)
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
INSERT OR IGNORE INTO settings (key, value) VALUES
('server_name', 'OwnCord Server'),
('motd', 'Welcome!');
CREATE TABLE IF NOT EXISTS dm_participants (
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (channel_id, user_id)
);
CREATE TABLE IF NOT EXISTS dm_open_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
opened_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (user_id, channel_id)
);
CREATE TABLE IF NOT EXISTS user_blocks (
blocker_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
blocked_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (blocker_id, blocked_id),
CHECK (blocker_id != blocked_id)
);
`)