2026-03-17 11:05:52 +01:00
|
|
|
package ws_test
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-23 17:03:52 +02:00
|
|
|
"context"
|
2026-03-17 11:05:52 +01:00
|
|
|
"encoding/json"
|
|
|
|
|
"testing"
|
|
|
|
|
"testing/fstest"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/auth"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
|
|
|
|
"github.com/J3vb/OwnCord/Server/ws"
|
2026-03-17 11:05:52 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ─── schema used by serve tests ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// serveTestSchema extends hubTestSchema with voice_states so that
|
|
|
|
|
// collectAllVoiceStates can be exercised via buildReady.
|
|
|
|
|
var serveTestSchema = append(hubTestSchema, []byte(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS voice_states (
|
|
|
|
|
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
|
|
|
|
channel_id INTEGER NOT NULL REFERENCES channels(id) ON DELETE CASCADE,
|
|
|
|
|
muted INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
deafened INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
speaking INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
camera INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
screenshare INTEGER NOT NULL DEFAULT 0,
|
2026-08-01 22:06:14 +02:00
|
|
|
server_muted INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
server_deafened INTEGER NOT NULL DEFAULT 0,
|
2026-03-17 11:05:52 +01:00
|
|
|
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_voice_states_channel_serve ON voice_states(channel_id);
|
|
|
|
|
|
|
|
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
|
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
|
|
|
actor_id INTEGER NOT NULL REFERENCES users(id),
|
|
|
|
|
action TEXT NOT NULL,
|
|
|
|
|
target_type TEXT NOT NULL DEFAULT '',
|
|
|
|
|
target_id INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
|
|
|
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
|
|
|
);
|
|
|
|
|
`)...)
|
|
|
|
|
|
|
|
|
|
func openServeTestDB(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: serveTestSchema},
|
|
|
|
|
}
|
|
|
|
|
if err := db.MigrateFS(database, migrFS); err != nil {
|
|
|
|
|
t.Fatalf("MigrateFS: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return database
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newServeHub(t *testing.T) (*ws.Hub, *db.DB) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
database := openServeTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
2026-04-05 20:42:32 +00:00
|
|
|
hub := ws.NewHub(database, limiter, nil)
|
2026-03-17 11:05:52 +01:00
|
|
|
go hub.Run()
|
|
|
|
|
t.Cleanup(func() { hub.Stop() })
|
|
|
|
|
return hub, database
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedServeUser inserts an Owner-role user and returns the full *db.User.
|
|
|
|
|
func seedServeUser(t *testing.T, database *db.DB, username string) *db.User {
|
|
|
|
|
t.Helper()
|
2026-07-23 17:03:52 +02:00
|
|
|
_, err := database.CreateUser(context.Background(), username, "hash", 1)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("seedServeUser: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
user, err := database.GetUserByUsername(context.Background(), username)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil || user == nil {
|
|
|
|
|
t.Fatalf("seedServeUser GetUserByUsername: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return user
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:29:10 +02:00
|
|
|
// ownerRole fetches the Owner role (ID=1) for permission-aware buildReady calls.
|
|
|
|
|
func ownerRole(t *testing.T, database *db.DB) *db.Role {
|
|
|
|
|
t.Helper()
|
2026-07-23 17:03:52 +02:00
|
|
|
role, err := database.GetRoleByID(context.Background(), 1)
|
2026-04-02 11:29:10 +02:00
|
|
|
if err != nil || role == nil {
|
|
|
|
|
t.Fatalf("ownerRole: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return role
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:05:52 +01:00
|
|
|
// ─── buildAuthOK ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildAuthOK_Type(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "authok-user1")
|
|
|
|
|
|
|
|
|
|
msg := hub.BuildAuthOKForTest(user, "admin")
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "auth_ok" {
|
|
|
|
|
t.Errorf("type = %q, want auth_ok", env.Type)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildAuthOK_UserPayload(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "authok-user2")
|
|
|
|
|
|
|
|
|
|
msg := hub.BuildAuthOKForTest(user, "member")
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
User struct {
|
|
|
|
|
ID int64 `json:"id"`
|
|
|
|
|
Username string `json:"username"`
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
} `json:"user"`
|
|
|
|
|
ServerName string `json:"server_name"`
|
|
|
|
|
MOTD string `json:"motd"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.User.ID != user.ID {
|
|
|
|
|
t.Errorf("payload.user.id = %d, want %d", env.Payload.User.ID, user.ID)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.User.Username != user.Username {
|
|
|
|
|
t.Errorf("payload.user.username = %q, want %q", env.Payload.User.Username, user.Username)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.User.Role != "member" {
|
|
|
|
|
t.Errorf("payload.user.role = %q, want member", env.Payload.User.Role)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildAuthOK_ContainsServerName(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "authok-user3")
|
|
|
|
|
|
|
|
|
|
msg := hub.BuildAuthOKForTest(user, "owner")
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
ServerName string `json:"server_name"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// server_name is seeded from settings table; must be non-empty.
|
|
|
|
|
if env.Payload.ServerName == "" {
|
|
|
|
|
t.Error("payload.server_name must not be empty")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildAuthOK_NilAvatar(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "authok-noavatar")
|
|
|
|
|
// Avatar is nil by default after insert.
|
|
|
|
|
|
|
|
|
|
msg := hub.BuildAuthOKForTest(user, "member")
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
User struct {
|
|
|
|
|
Avatar any `json:"avatar"`
|
|
|
|
|
} `json:"user"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.User.Avatar != nil {
|
|
|
|
|
t.Errorf("payload.user.avatar = %v, want nil", env.Payload.User.Avatar)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildAuthOK_ValidJSON(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "authok-validjson")
|
|
|
|
|
msg := hub.BuildAuthOKForTest(user, "member")
|
|
|
|
|
if !json.Valid(msg) {
|
|
|
|
|
t.Errorf("buildAuthOK output is not valid JSON: %s", msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── buildReady ───────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_Type(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "ready-user1")
|
|
|
|
|
|
|
|
|
|
msg, err := hub.BuildReadyForTest(database, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "ready" {
|
|
|
|
|
t.Errorf("type = %q, want ready", env.Type)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_ContainsRequiredFields(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "ready-user2")
|
|
|
|
|
|
|
|
|
|
msg, err := hub.BuildReadyForTest(database, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
Channels []any `json:"channels"`
|
|
|
|
|
Members []any `json:"members"`
|
|
|
|
|
VoiceStates []any `json:"voice_states"`
|
|
|
|
|
Roles []any `json:"roles"`
|
|
|
|
|
ServerName string `json:"server_name"`
|
|
|
|
|
MOTD string `json:"motd"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// channels, members, voice_states, roles must all be present (even if empty slices).
|
|
|
|
|
if env.Payload.Channels == nil {
|
|
|
|
|
t.Error("payload.channels must not be nil")
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.Members == nil {
|
|
|
|
|
t.Error("payload.members must not be nil")
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.VoiceStates == nil {
|
|
|
|
|
t.Error("payload.voice_states must not be nil")
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.Roles == nil {
|
|
|
|
|
t.Error("payload.roles must not be nil")
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.ServerName == "" {
|
|
|
|
|
t.Error("payload.server_name must not be empty")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_IncludesSeededChannel(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "ready-user3")
|
2026-04-02 11:29:10 +02:00
|
|
|
role := ownerRole(t, database)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
// Seed a text channel.
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, err := database.CreateChannel(context.Background(), "general", "text", "", "", 0)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:29:10 +02:00
|
|
|
msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
2026-04-02 11:29:10 +02:00
|
|
|
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
|
2026-03-17 11:05:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
Channels []struct {
|
|
|
|
|
ID float64 `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
} `json:"channels"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
found := false
|
|
|
|
|
for _, ch := range env.Payload.Channels {
|
|
|
|
|
if int64(ch.ID) == chID && ch.Name == "general" && ch.Type == "text" {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Errorf("ready payload does not include seeded channel (id=%d)", chID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_TextChannelHasUnreadCount(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "ready-user4")
|
2026-04-02 11:29:10 +02:00
|
|
|
role := ownerRole(t, database)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
_, err := database.CreateChannel(context.Background(), "unread-chan", "text", "", "", 0)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:29:10 +02:00
|
|
|
msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
2026-04-02 11:29:10 +02:00
|
|
|
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
|
2026-03-17 11:05:52 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
Channels []map[string]any `json:"channels"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, ch := range env.Payload.Channels {
|
|
|
|
|
if ch["type"] == "text" {
|
|
|
|
|
if _, ok := ch["unread_count"]; !ok {
|
|
|
|
|
t.Error("text channel missing unread_count field")
|
|
|
|
|
}
|
|
|
|
|
if _, ok := ch["last_message_id"]; !ok {
|
|
|
|
|
t.Error("text channel missing last_message_id field")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_ValidJSON(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "ready-validjson")
|
|
|
|
|
msg, err := hub.BuildReadyForTest(database, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !json.Valid(msg) {
|
|
|
|
|
t.Errorf("buildReady output is not valid JSON: %s", msg)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── collectAllVoiceStates ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestCollectAllVoiceStates_EmptyChannels(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "collect-empty-user")
|
|
|
|
|
|
|
|
|
|
// No channels exist — ready should return empty voice_states.
|
|
|
|
|
msg, err := hub.BuildReadyForTest(database, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
VoiceStates []any `json:"voice_states"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(env.Payload.VoiceStates) != 0 {
|
|
|
|
|
t.Errorf("voice_states = %d entries, want 0 with no channels", len(env.Payload.VoiceStates))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCollectAllVoiceStates_SkipsTextChannels(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "collect-text-user")
|
|
|
|
|
|
|
|
|
|
// Only text channels — no voice states should be collected.
|
2026-07-23 17:03:52 +02:00
|
|
|
_, err := database.CreateChannel(context.Background(), "text-only", "text", "", "", 0)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg, err := hub.BuildReadyForTest(database, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
VoiceStates []any `json:"voice_states"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(env.Payload.VoiceStates) != 0 {
|
|
|
|
|
t.Errorf("voice_states = %d entries, want 0 for text-only channels", len(env.Payload.VoiceStates))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCollectAllVoiceStates_IncludesVoiceParticipants(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
2026-04-02 11:40:16 +02:00
|
|
|
role := ownerRole(t, database)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
user1 := seedServeUser(t, database, "collect-voice-u1")
|
|
|
|
|
user2 := seedServeUser(t, database, "collect-voice-u2")
|
|
|
|
|
requester := seedServeUser(t, database, "collect-voice-req")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
chID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Insert voice states for user1 and user2.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), user1.ID, chID); err != nil {
|
2026-03-17 11:05:52 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel user1: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), user2.ID, chID); err != nil {
|
2026-03-17 11:05:52 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel user2: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:40:16 +02:00
|
|
|
msg, err := hub.BuildReadyWithRoleForTest(database, requester.ID, role)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
2026-04-02 11:40:16 +02:00
|
|
|
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
|
2026-03-17 11:05:52 +01:00
|
|
|
}
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
VoiceStates []struct {
|
|
|
|
|
ChannelID int64 `json:"channel_id"`
|
|
|
|
|
UserID int64 `json:"user_id"`
|
|
|
|
|
} `json:"voice_states"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(env.Payload.VoiceStates) != 2 {
|
|
|
|
|
t.Errorf("voice_states count = %d, want 2", len(env.Payload.VoiceStates))
|
|
|
|
|
}
|
|
|
|
|
for _, vs := range env.Payload.VoiceStates {
|
|
|
|
|
if vs.ChannelID != chID {
|
|
|
|
|
t.Errorf("voice_state channel_id = %d, want %d", vs.ChannelID, chID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-02 11:40:16 +02:00
|
|
|
// ─── Voice state filtering by channel visibility (BUG-095) ───────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_VoiceStatesFilteredByVisibility(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
// Create a member user (role 4, permissions=1635, includes ReadMessages).
|
2026-07-23 17:03:52 +02:00
|
|
|
_, err := database.CreateUser(context.Background(), "vs-member", "hash", 4)
|
2026-04-02 11:40:16 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateUser: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
member, err := database.GetUserByUsername(context.Background(), "vs-member")
|
2026-04-02 11:40:16 +02:00
|
|
|
if err != nil || member == nil {
|
|
|
|
|
t.Fatalf("GetUserByUsername: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
memberRole, err := database.GetRoleByID(context.Background(), 4)
|
2026-04-02 11:40:16 +02:00
|
|
|
if err != nil || memberRole == nil {
|
|
|
|
|
t.Fatalf("GetRoleByID: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create two voice channels: one visible, one denied.
|
2026-07-23 17:03:52 +02:00
|
|
|
visibleCh, err := database.CreateChannel(context.Background(), "public-voice", "voice", "", "", 0)
|
2026-04-02 11:40:16 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel visible: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
hiddenCh, err := database.CreateChannel(context.Background(), "hidden-voice", "voice", "", "", 1)
|
2026-04-02 11:40:16 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel hidden: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Deny READ_MESSAGES on the hidden channel for Member role (role 4).
|
2026-07-23 17:03:52 +02:00
|
|
|
_, err = database.ExecContext(context.Background(),
|
2026-04-02 11:40:16 +02:00
|
|
|
`INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 2)`,
|
|
|
|
|
hiddenCh,
|
|
|
|
|
)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("insert channel_override: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create users in both voice channels.
|
|
|
|
|
u1 := seedServeUser(t, database, "vs-visible-user")
|
|
|
|
|
u2 := seedServeUser(t, database, "vs-hidden-user")
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u1.ID, visibleCh); err != nil {
|
2026-04-02 11:40:16 +02:00
|
|
|
t.Fatalf("JoinVoiceChannel visible: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u2.ID, hiddenCh); err != nil {
|
2026-04-02 11:40:16 +02:00
|
|
|
t.Fatalf("JoinVoiceChannel hidden: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Build ready for the member — should only see voice states for visible channel.
|
|
|
|
|
msg, err := hub.BuildReadyWithRoleForTest(database, member.ID, memberRole)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
VoiceStates []struct {
|
|
|
|
|
ChannelID int64 `json:"channel_id"`
|
|
|
|
|
} `json:"voice_states"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(env.Payload.VoiceStates) != 1 {
|
|
|
|
|
t.Fatalf("voice_states count = %d, want 1 (only visible channel)", len(env.Payload.VoiceStates))
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.VoiceStates[0].ChannelID != visibleCh {
|
|
|
|
|
t.Errorf("voice_state channel_id = %d, want %d (visible)", env.Payload.VoiceStates[0].ChannelID, visibleCh)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 11:05:52 +01:00
|
|
|
// ─── getCachedSettings ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestGetCachedSettings_CacheHit(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
// Call twice in quick succession; second call must return the same values
|
|
|
|
|
// (cache hit path, no DB re-read within TTL).
|
|
|
|
|
name1, motd1 := hub.GetCachedSettingsForTest()
|
|
|
|
|
name2, motd2 := hub.GetCachedSettingsForTest()
|
|
|
|
|
|
|
|
|
|
if name1 != name2 {
|
|
|
|
|
t.Errorf("server_name changed between calls: %q vs %q", name1, name2)
|
|
|
|
|
}
|
|
|
|
|
if motd1 != motd2 {
|
|
|
|
|
t.Errorf("motd changed between calls: %q vs %q", motd1, motd2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetCachedSettings_ReturnsNonEmptyValues(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
name, motd := hub.GetCachedSettingsForTest()
|
|
|
|
|
if name == "" {
|
|
|
|
|
t.Error("server_name must not be empty after NewHub")
|
|
|
|
|
}
|
|
|
|
|
if motd == "" {
|
|
|
|
|
t.Error("motd must not be empty after NewHub")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetCachedSettings_ReflectsDBValues(t *testing.T) {
|
|
|
|
|
_, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
// Verify the default settings were loaded correctly from the seeded DB.
|
|
|
|
|
var name string
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.QueryRowContext(context.Background(), "SELECT value FROM settings WHERE key='server_name'").Scan(&name); err != nil {
|
2026-03-17 11:05:52 +01:00
|
|
|
t.Fatalf("query server_name: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if name != "OwnCord Server" {
|
|
|
|
|
t.Errorf("DB server_name = %q, want OwnCord Server", name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Broadcast* hub methods ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastServerRestart_DeliversToAllClients(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
u1 := seedTestUser(t, database, "restart-u1")
|
|
|
|
|
u2 := seedTestUser(t, database, "restart-u2")
|
|
|
|
|
s1 := make(chan []byte, 4)
|
|
|
|
|
s2 := make(chan []byte, 4)
|
2026-07-31 15:41:57 +02:00
|
|
|
c2 := ws.NewTestClient(hub, u2, s2)
|
2026-03-17 11:05:52 +01:00
|
|
|
hub.Register(ws.NewTestClient(hub, u1, s1))
|
2026-07-31 15:41:57 +02:00
|
|
|
hub.Register(c2)
|
|
|
|
|
waitRegistered(t, hub, c2) // in-order events: both clients registered
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
hub.BroadcastServerRestart("update", 5)
|
2026-07-31 15:41:57 +02:00
|
|
|
// The receive selects below block with their own timeout.
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
for _, s := range []chan []byte{s1, s2} {
|
|
|
|
|
select {
|
|
|
|
|
case msg := <-s:
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Payload struct {
|
|
|
|
|
Reason string `json:"reason"`
|
|
|
|
|
DelaySeconds int `json:"delay_seconds"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "server_restart" {
|
|
|
|
|
t.Errorf("type = %q, want server_restart", env.Type)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.Reason != "update" {
|
|
|
|
|
t.Errorf("payload.reason = %q, want update", env.Payload.Reason)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.DelaySeconds != 5 {
|
|
|
|
|
t.Errorf("payload.delay_seconds = %d, want 5", env.Payload.DelaySeconds)
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
t.Error("client did not receive server_restart within timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastServerRestart_NoClients_NoPanic(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
// Must not panic with no clients connected.
|
|
|
|
|
hub.BroadcastServerRestart("maintenance", 30)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastChannelCreate_DeliversToAllClients(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
u1 := seedTestUser(t, database, "chcreate-u1")
|
|
|
|
|
s1 := make(chan []byte, 4)
|
2026-07-31 15:41:57 +02:00
|
|
|
c1 := ws.NewTestClient(hub, u1, s1)
|
|
|
|
|
hub.Register(c1)
|
|
|
|
|
waitRegistered(t, hub, c1)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
2026-08-19 18:28:00 +02:00
|
|
|
// Seed a real row: every production caller broadcasts a channel already
|
|
|
|
|
// committed to the DB, and channelReadAudience fails closed on a missing
|
|
|
|
|
// row (OC-0090) — a fabricated id would resolve to an empty audience.
|
|
|
|
|
chID, err := database.CreateChannel(context.Background(), "announcements", "text", "News", "", 1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
ch, err := database.GetChannel(context.Background(), chID)
|
|
|
|
|
if err != nil || ch == nil {
|
|
|
|
|
t.Fatalf("GetChannel: ch=%v err=%v", ch, err)
|
|
|
|
|
}
|
2026-03-17 11:05:52 +01:00
|
|
|
hub.BroadcastChannelCreate(ch)
|
2026-07-31 15:41:57 +02:00
|
|
|
// The receive select below blocks with its own timeout.
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case msg := <-s1:
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Payload struct {
|
|
|
|
|
ID float64 `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "channel_create" {
|
|
|
|
|
t.Errorf("type = %q, want channel_create", env.Type)
|
|
|
|
|
}
|
|
|
|
|
if int64(env.Payload.ID) != ch.ID {
|
|
|
|
|
t.Errorf("payload.id = %d, want %d", int64(env.Payload.ID), ch.ID)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.Name != ch.Name {
|
|
|
|
|
t.Errorf("payload.name = %q, want %q", env.Payload.Name, ch.Name)
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
t.Error("client did not receive channel_create within timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastChannelUpdate_DeliversToAllClients(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
u1 := seedTestUser(t, database, "chupdate-u1")
|
|
|
|
|
s1 := make(chan []byte, 4)
|
2026-07-31 15:41:57 +02:00
|
|
|
c1 := ws.NewTestClient(hub, u1, s1)
|
|
|
|
|
hub.Register(c1)
|
|
|
|
|
waitRegistered(t, hub, c1)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
2026-08-19 18:28:00 +02:00
|
|
|
// Seed a real row — see the channel_create test above (OC-0090).
|
|
|
|
|
chID, err := database.CreateChannel(context.Background(), "updated-channel", "text", "General", "", 2)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
ch, err := database.GetChannel(context.Background(), chID)
|
|
|
|
|
if err != nil || ch == nil {
|
|
|
|
|
t.Fatalf("GetChannel: ch=%v err=%v", ch, err)
|
|
|
|
|
}
|
2026-03-17 11:05:52 +01:00
|
|
|
hub.BroadcastChannelUpdate(ch)
|
2026-07-31 15:41:57 +02:00
|
|
|
// The receive select below blocks with its own timeout.
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case msg := <-s1:
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Payload struct {
|
|
|
|
|
ID float64 `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "channel_update" {
|
|
|
|
|
t.Errorf("type = %q, want channel_update", env.Type)
|
|
|
|
|
}
|
|
|
|
|
if int64(env.Payload.ID) != ch.ID {
|
|
|
|
|
t.Errorf("payload.id = %d, want %d", int64(env.Payload.ID), ch.ID)
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
t.Error("client did not receive channel_update within timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastChannelDelete_DeliversToAllClients(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
u1 := seedTestUser(t, database, "chdel-u1")
|
|
|
|
|
s1 := make(chan []byte, 4)
|
2026-07-31 15:41:57 +02:00
|
|
|
c1 := ws.NewTestClient(hub, u1, s1)
|
|
|
|
|
hub.Register(c1)
|
|
|
|
|
waitRegistered(t, hub, c1)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
hub.BroadcastChannelDelete(123)
|
2026-07-31 15:41:57 +02:00
|
|
|
// The receive select below blocks with its own timeout.
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case msg := <-s1:
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Payload struct {
|
|
|
|
|
ID float64 `json:"id"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "channel_delete" {
|
|
|
|
|
t.Errorf("type = %q, want channel_delete", env.Type)
|
|
|
|
|
}
|
|
|
|
|
if int64(env.Payload.ID) != 123 {
|
|
|
|
|
t.Errorf("payload.id = %d, want 123", int64(env.Payload.ID))
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
t.Error("client did not receive channel_delete within timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastMemberBan_DeliversToAllClients(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
u1 := seedTestUser(t, database, "ban-u1")
|
|
|
|
|
s1 := make(chan []byte, 4)
|
2026-07-31 15:41:57 +02:00
|
|
|
c1 := ws.NewTestClient(hub, u1, s1)
|
|
|
|
|
hub.Register(c1)
|
|
|
|
|
waitRegistered(t, hub, c1)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
hub.BroadcastMemberBan(999)
|
2026-07-31 15:41:57 +02:00
|
|
|
// The receive select below blocks with its own timeout.
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case msg := <-s1:
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Payload struct {
|
|
|
|
|
UserID float64 `json:"user_id"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "member_ban" {
|
|
|
|
|
t.Errorf("type = %q, want member_ban", env.Type)
|
|
|
|
|
}
|
|
|
|
|
if int64(env.Payload.UserID) != 999 {
|
|
|
|
|
t.Errorf("payload.user_id = %d, want 999", int64(env.Payload.UserID))
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
t.Error("client did not receive member_ban within timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastMemberUpdate_DeliversToAllClients(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
u1 := seedTestUser(t, database, "memupdate-u1")
|
|
|
|
|
s1 := make(chan []byte, 4)
|
2026-07-31 15:41:57 +02:00
|
|
|
c1 := ws.NewTestClient(hub, u1, s1)
|
|
|
|
|
hub.Register(c1)
|
|
|
|
|
waitRegistered(t, hub, c1)
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
hub.BroadcastMemberUpdate(888, "moderator")
|
2026-07-31 15:41:57 +02:00
|
|
|
// The receive select below blocks with its own timeout.
|
2026-03-17 11:05:52 +01:00
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case msg := <-s1:
|
|
|
|
|
var env struct {
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Payload struct {
|
|
|
|
|
UserID float64 `json:"user_id"`
|
|
|
|
|
Role string `json:"role"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Type != "member_update" {
|
|
|
|
|
t.Errorf("type = %q, want member_update", env.Type)
|
|
|
|
|
}
|
|
|
|
|
if int64(env.Payload.UserID) != 888 {
|
|
|
|
|
t.Errorf("payload.user_id = %d, want 888", int64(env.Payload.UserID))
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.Role != "moderator" {
|
|
|
|
|
t.Errorf("payload.role = %q, want moderator", env.Payload.Role)
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
t.Error("client did not receive member_update within timeout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastMemberBan_NoClients_NoPanic(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
hub.BroadcastMemberBan(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastMemberUpdate_NoClients_NoPanic(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
hub.BroadcastMemberUpdate(1, "member")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastChannelCreate_NoClients_NoPanic(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
hub.BroadcastChannelCreate(&db.Channel{ID: 1, Name: "x", Type: "text"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastChannelUpdate_NoClients_NoPanic(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
hub.BroadcastChannelUpdate(&db.Channel{ID: 1, Name: "x", Type: "text"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHub_BroadcastChannelDelete_NoClients_NoPanic(t *testing.T) {
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
hub.BroadcastChannelDelete(1)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── getCachedSettings — cache expiry path ────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestGetCachedSettings_CacheMiss_RefreshesFromDB(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
|
|
|
|
|
// Update the DB settings value so we can detect a refresh.
|
2026-07-23 17:03:52 +02:00
|
|
|
_, err := database.ExecContext(context.Background(), "UPDATE settings SET value='Refreshed Server' WHERE key='server_name'")
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UPDATE settings: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Force the cache to appear stale.
|
|
|
|
|
hub.ExpireSettingsCacheForTest()
|
|
|
|
|
|
|
|
|
|
// Next call must re-read from the DB and return the updated value.
|
|
|
|
|
name, _ := hub.GetCachedSettingsForTest()
|
|
|
|
|
if name != "Refreshed Server" {
|
|
|
|
|
t.Errorf("server_name after cache miss = %q, want Refreshed Server", name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetCachedSettings_CacheMiss_DoubleCheck(t *testing.T) {
|
|
|
|
|
// Expire the cache and call twice rapidly to exercise the double-check
|
|
|
|
|
// (write-lock re-check) branch inside getCachedSettings.
|
|
|
|
|
hub, _ := newServeHub(t)
|
|
|
|
|
hub.ExpireSettingsCacheForTest()
|
|
|
|
|
|
|
|
|
|
name1, _ := hub.GetCachedSettingsForTest()
|
|
|
|
|
// Second call should hit the cache (now warm).
|
|
|
|
|
name2, _ := hub.GetCachedSettingsForTest()
|
|
|
|
|
if name1 != name2 {
|
|
|
|
|
t.Errorf("server_name changed after refresh: %q vs %q", name1, name2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── parseChannelID error paths ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestParseChannelID_ValidPayload(t *testing.T) {
|
|
|
|
|
raw := json.RawMessage(`{"channel_id": 42}`)
|
|
|
|
|
id, err := ws.ParseChannelIDForTest(raw)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("ParseChannelIDForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if id != 42 {
|
|
|
|
|
t.Errorf("channel_id = %d, want 42", id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseChannelID_InvalidJSON(t *testing.T) {
|
|
|
|
|
raw := json.RawMessage(`NOT JSON`)
|
|
|
|
|
_, err := ws.ParseChannelIDForTest(raw)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Error("expected error for invalid JSON, got nil")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseChannelID_NonIntegerChannelID(t *testing.T) {
|
|
|
|
|
raw := json.RawMessage(`{"channel_id": "not-a-number"}`)
|
|
|
|
|
_, err := ws.ParseChannelIDForTest(raw)
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Error("expected error for non-integer channel_id, got nil")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestParseChannelID_MissingField(t *testing.T) {
|
|
|
|
|
// Missing channel_id field — json.Number.Int64 on zero value returns 0, no error.
|
|
|
|
|
raw := json.RawMessage(`{}`)
|
|
|
|
|
id, err := ws.ParseChannelIDForTest(raw)
|
|
|
|
|
if err == nil && id != 0 {
|
|
|
|
|
t.Errorf("expected id=0 for missing channel_id, got %d", id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── buildJSON error fallback path ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildJSON_ValidValue_ReturnsJSON(t *testing.T) {
|
|
|
|
|
// Normal path: marshalable value produces valid JSON.
|
|
|
|
|
out := ws.BuildJSONForTest(map[string]string{"type": "test"})
|
|
|
|
|
if !json.Valid(out) {
|
|
|
|
|
t.Errorf("BuildJSONForTest output is not valid JSON: %s", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── buildReady error path (nil members fallback) ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_NoVoiceChannels_EmptyVoiceStates(t *testing.T) {
|
|
|
|
|
hub, database := newServeHub(t)
|
|
|
|
|
user := seedServeUser(t, database, "ready-novch")
|
|
|
|
|
|
|
|
|
|
// Create only a text channel — voice_states list must still be non-nil.
|
2026-07-23 17:03:52 +02:00
|
|
|
_, err := database.CreateChannel(context.Background(), "text-chan", "text", "", "", 0)
|
2026-03-17 11:05:52 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg, err := hub.BuildReadyForTest(database, user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
VoiceStates []any `json:"voice_states"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// collectAllVoiceStates returns []db.VoiceState{} (not nil) when no voice channels exist.
|
|
|
|
|
if env.Payload.VoiceStates == nil {
|
|
|
|
|
t.Error("voice_states must be a non-null JSON array even when empty")
|
|
|
|
|
}
|
|
|
|
|
}
|