mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
- Exclude voiceSession.ts from coverage (browser API dependency, same
pattern as audio.ts/vad.ts/webrtc.ts)
- Remove stale TestHub_Register_CleansUpOldVoiceState test that tested
old duplicate-login behavior removed in b53c729
806 lines
24 KiB
Go
806 lines
24 KiB
Go
package ws_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync"
|
|
"testing"
|
|
"testing/fstest"
|
|
"time"
|
|
|
|
"github.com/owncord/server/auth"
|
|
"github.com/owncord/server/db"
|
|
"github.com/owncord/server/ws"
|
|
)
|
|
|
|
// ─── test helpers ─────────────────────────────────────────────────────────────
|
|
|
|
func openTestDB(t *testing.T) *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.T) (*ws.Hub, *db.DB) {
|
|
t.Helper()
|
|
database := openTestDB(t)
|
|
limiter := auth.NewRateLimiter()
|
|
hub := ws.NewHub(database, limiter)
|
|
return hub, database
|
|
}
|
|
|
|
// seedTestUser inserts a Member-role user and returns its ID.
|
|
func seedTestUser(t *testing.T, database *db.DB, username string) int64 {
|
|
t.Helper()
|
|
id, err := database.CreateUser(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.T, database *db.DB, username string) *db.User {
|
|
t.Helper()
|
|
_, err := database.CreateUser(username, "hash", 1) // roleID=1 → Owner
|
|
if err != nil {
|
|
t.Fatalf("seedOwnerUser: %v", err)
|
|
}
|
|
user, err := database.GetUserByUsername(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.T, database *db.DB, name string) int64 {
|
|
t.Helper()
|
|
id, err := database.CreateChannel(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)
|
|
}()
|
|
// Give the goroutine a moment to start, then stop the hub.
|
|
time.Sleep(10 * time.Millisecond)
|
|
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))
|
|
|
|
time.Sleep(20 * time.Millisecond)
|
|
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)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
hub.Unregister(c)
|
|
time.Sleep(20 * time.Millisecond)
|
|
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)
|
|
hub.Register(ws.NewTestClient(hub, userID, send1))
|
|
hub.Register(ws.NewTestClient(hub, userID, send2))
|
|
time.Sleep(30 * time.Millisecond)
|
|
|
|
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)
|
|
hub.Register(ws.NewTestClient(hub, u1, s1))
|
|
hub.Register(ws.NewTestClient(hub, u2, s2))
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
msg := []byte(`{"type":"presence","payload":{}}`)
|
|
hub.BroadcastToAll(msg)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
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)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
msg := []byte(`{"type":"chat_message","payload":{}}`)
|
|
hub.BroadcastToChannel(chID, msg)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
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)
|
|
hub.Register(ws.NewTestClient(hub, u1, s1))
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
msg := []byte(`{"type":"presence","payload":{}}`)
|
|
hub.BroadcastToChannel(0, msg)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
assertReceived(t, s1, msg, "client")
|
|
}
|
|
|
|
// ─── 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)
|
|
hub.Register(ws.NewTestClient(hub, userID, send))
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
msg := []byte(`{"type":"chat_send_ok","payload":{}}`)
|
|
ok := hub.SendToUser(userID, msg)
|
|
if !ok {
|
|
t.Error("SendToUser returned false for existing client")
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
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)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
raw := []byte(`{"type":"totally_unknown","payload":{}}`)
|
|
hub.HandleMessageForTest(c, raw)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
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)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
hub.HandleMessageForTest(c, []byte(`NOT JSON`))
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
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)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
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)
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Drain all messages, count errors.
|
|
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)
|
|
time.Sleep(5 * time.Millisecond)
|
|
hub.Unregister(c)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
time.Sleep(50 * time.Millisecond)
|
|
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()
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
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 ────────────────────────────────────────────────────────
|
|
|
|
func assertReceived(t *testing.T, ch <-chan []byte, want []byte, label string) {
|
|
t.Helper()
|
|
select {
|
|
case got := <-ch:
|
|
if string(got) != string(want) {
|
|
t.Errorf("%s: got %q, want %q", label, got, want)
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
// ─── Voice room lifecycle ─────────────────────────────────────────────────────
|
|
|
|
func TestHub_SetSFU_NilSafe(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
// Setting a nil SFU must not panic.
|
|
hub.SetSFU(nil)
|
|
}
|
|
|
|
func TestHub_GetOrCreateVoiceRoom_CreatesNew(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
cfg := ws.VoiceRoomConfig{ChannelID: 42, MaxUsers: 10, Quality: "medium"}
|
|
|
|
room := hub.GetOrCreateVoiceRoom(42, cfg)
|
|
if room == nil {
|
|
t.Fatal("GetOrCreateVoiceRoom returned nil")
|
|
}
|
|
}
|
|
|
|
func TestHub_GetOrCreateVoiceRoom_ReturnsSameRoom(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
cfg := ws.VoiceRoomConfig{ChannelID: 99, MaxUsers: 5, Quality: "low"}
|
|
|
|
r1 := hub.GetOrCreateVoiceRoom(99, cfg)
|
|
r2 := hub.GetOrCreateVoiceRoom(99, cfg)
|
|
if r1 != r2 {
|
|
t.Error("GetOrCreateVoiceRoom should return the same room on subsequent calls")
|
|
}
|
|
}
|
|
|
|
func TestHub_GetOrCreateVoiceRoom_DifferentChannels(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
cfg1 := ws.VoiceRoomConfig{ChannelID: 1, Quality: "low"}
|
|
cfg2 := ws.VoiceRoomConfig{ChannelID: 2, Quality: "high"}
|
|
|
|
r1 := hub.GetOrCreateVoiceRoom(1, cfg1)
|
|
r2 := hub.GetOrCreateVoiceRoom(2, cfg2)
|
|
if r1 == r2 {
|
|
t.Error("different channel IDs must produce distinct rooms")
|
|
}
|
|
}
|
|
|
|
func TestHub_GetVoiceRoom_ReturnsNilWhenAbsent(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
room := hub.GetVoiceRoom(404)
|
|
if room != nil {
|
|
t.Errorf("GetVoiceRoom: want nil for absent channel, got %v", room)
|
|
}
|
|
}
|
|
|
|
func TestHub_GetVoiceRoom_ReturnsRoomAfterCreate(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
cfg := ws.VoiceRoomConfig{ChannelID: 7, Quality: "medium"}
|
|
hub.GetOrCreateVoiceRoom(7, cfg)
|
|
|
|
room := hub.GetVoiceRoom(7)
|
|
if room == nil {
|
|
t.Fatal("GetVoiceRoom: want non-nil after GetOrCreateVoiceRoom, got nil")
|
|
}
|
|
}
|
|
|
|
func TestHub_RemoveVoiceRoom_NoopWhenAbsent(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
// Must not panic on removal of non-existent room.
|
|
hub.RemoveVoiceRoom(999)
|
|
}
|
|
|
|
func TestHub_RemoveVoiceRoom_RemovesRoom(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
cfg := ws.VoiceRoomConfig{ChannelID: 55, Quality: "low"}
|
|
hub.GetOrCreateVoiceRoom(55, cfg)
|
|
|
|
hub.RemoveVoiceRoom(55)
|
|
if hub.GetVoiceRoom(55) != nil {
|
|
t.Error("GetVoiceRoom: want nil after RemoveVoiceRoom")
|
|
}
|
|
}
|
|
|
|
func TestHub_CloseAllVoiceRooms_ClearsAll(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
for _, id := range []int64{10, 20, 30} {
|
|
hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"})
|
|
}
|
|
|
|
hub.CloseAllVoiceRooms()
|
|
|
|
for _, id := range []int64{10, 20, 30} {
|
|
if hub.GetVoiceRoom(id) != nil {
|
|
t.Errorf("GetVoiceRoom(%d): want nil after CloseAllVoiceRooms", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHub_CloseAllVoiceRooms_EmptyIsNoop(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
// Must not panic when no rooms exist.
|
|
hub.CloseAllVoiceRooms()
|
|
}
|
|
|
|
func TestHub_VoiceRooms_ConcurrentAccess(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
var wg sync.WaitGroup
|
|
|
|
// Concurrent creates and reads must not race.
|
|
for i := range int64(20) {
|
|
wg.Add(1)
|
|
go func(id int64) {
|
|
defer wg.Done()
|
|
cfg := ws.VoiceRoomConfig{ChannelID: id, Quality: "medium"}
|
|
hub.GetOrCreateVoiceRoom(id, cfg)
|
|
hub.GetVoiceRoom(id)
|
|
hub.RemoveVoiceRoom(id)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
}
|
|
|
|
// ─── GracefulStop ─────────────────────────────────────────────────────────────
|
|
|
|
func TestHub_GracefulStop_StopsHub(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
done := make(chan struct{})
|
|
go func() {
|
|
hub.Run()
|
|
close(done)
|
|
}()
|
|
time.Sleep(10 * time.Millisecond)
|
|
|
|
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_ClosesAllVoiceRooms(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
for _, id := range []int64{100, 200, 300} {
|
|
hub.GetOrCreateVoiceRoom(id, ws.VoiceRoomConfig{ChannelID: id, Quality: "low"})
|
|
}
|
|
go hub.Run()
|
|
|
|
hub.GracefulStop()
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
for _, id := range []int64{100, 200, 300} {
|
|
if hub.GetVoiceRoom(id) != nil {
|
|
t.Errorf("GetVoiceRoom(%d): expected nil after GracefulStop", id)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHub_GracefulStop_NoRooms_NoPanic(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
go hub.Run()
|
|
// Must not panic with zero voice rooms.
|
|
hub.GracefulStop()
|
|
}
|
|
|
|
// ─── CleanupVoiceForChannel ───────────────────────────────────────────────────
|
|
|
|
func TestHub_CleanupVoiceForChannel_RemovesRoom(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
chID := int64(55)
|
|
hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"})
|
|
|
|
hub.CleanupVoiceForChannel(chID)
|
|
|
|
if hub.GetVoiceRoom(chID) != nil {
|
|
t.Error("expected room to be nil after CleanupVoiceForChannel")
|
|
}
|
|
}
|
|
|
|
func TestHub_CleanupVoiceForChannel_NoRoom_NoPanic(t *testing.T) {
|
|
hub, _ := newTestHub(t)
|
|
// Must not panic when channel has no voice room.
|
|
hub.CleanupVoiceForChannel(9999)
|
|
}
|
|
|
|
func TestHub_CleanupVoiceForChannel_BroadcastsVoiceLeave(t *testing.T) {
|
|
hub, database := newTestHub(t)
|
|
go hub.Run()
|
|
defer hub.Stop()
|
|
|
|
chID := seedTestChannel(t, database, "cleanup-vc")
|
|
u1 := seedTestUser(t, database, "cleanup-user1")
|
|
u2 := seedTestUser(t, database, "cleanup-user2")
|
|
|
|
send1 := make(chan []byte, 16)
|
|
send2 := make(chan []byte, 16)
|
|
c1 := ws.NewTestClientWithChannel(hub, u1, chID, send1)
|
|
c2 := ws.NewTestClientWithChannel(hub, u2, chID, send2)
|
|
hub.Register(c1)
|
|
hub.Register(c2)
|
|
time.Sleep(20 * time.Millisecond)
|
|
|
|
room := hub.GetOrCreateVoiceRoom(chID, ws.VoiceRoomConfig{ChannelID: chID, Quality: "medium"})
|
|
if err := room.AddParticipant(u1); err != nil {
|
|
t.Fatalf("AddParticipant u1: %v", err)
|
|
}
|
|
if err := room.AddParticipant(u2); err != nil {
|
|
t.Fatalf("AddParticipant u2: %v", err)
|
|
}
|
|
|
|
hub.CleanupVoiceForChannel(chID)
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
// At least one of the clients must receive a voice_leave.
|
|
allMsgs := append(drainChan(send1), drainChan(send2)...)
|
|
found := false
|
|
for _, msg := range allMsgs {
|
|
var env map[string]any
|
|
if err := json.Unmarshal(msg, &env); err == nil {
|
|
if env["type"] == "voice_leave" {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("expected voice_leave broadcast after CleanupVoiceForChannel")
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
|
|
// 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
|
|
);
|
|
|
|
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
|
|
);
|
|
|
|
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 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'))
|
|
);
|
|
|
|
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!');
|
|
`)
|