2026-03-14 21:31:03 +01:00
|
|
|
package db_test
|
|
|
|
|
|
|
|
|
|
import (
|
2026-07-23 17:03:52 +02:00
|
|
|
"context"
|
2026-03-14 21:31:03 +01:00
|
|
|
"testing"
|
|
|
|
|
"testing/fstest"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/db"
|
2026-03-14 21:31:03 +01:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
var channelSchema = []byte(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS channels (
|
2026-03-15 11:42:25 +01:00
|
|
|
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,
|
2026-08-01 22:06:14 +02:00
|
|
|
voice_max_video INTEGER NOT NULL DEFAULT 10,
|
|
|
|
|
nsfw INTEGER NOT NULL DEFAULT 0,
|
|
|
|
|
is_group INTEGER NOT NULL DEFAULT 0
|
2026-03-14 21:31:03 +01:00
|
|
|
);
|
|
|
|
|
`)
|
|
|
|
|
|
|
|
|
|
// newVoiceTestDB opens an in-memory DB with users, channels, and voice_states.
|
|
|
|
|
func newVoiceTestDB(t *testing.T) *db.DB {
|
|
|
|
|
t.Helper()
|
|
|
|
|
database, err := db.Open(":memory:")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("db.Open: %v", err)
|
|
|
|
|
}
|
2026-03-17 08:09:52 +01:00
|
|
|
t.Cleanup(func() { _ = database.Close() })
|
2026-03-14 21:31:03 +01:00
|
|
|
|
|
|
|
|
migrFS := fstest.MapFS{
|
2026-03-15 11:42:25 +01:00
|
|
|
"001_schema.sql": {Data: testSchema},
|
2026-03-14 21:31:03 +01:00
|
|
|
"002_channels.sql": {Data: channelSchema},
|
|
|
|
|
"003_voice.sql": {Data: []byte(`
|
|
|
|
|
CREATE TABLE IF NOT EXISTS voice_states (
|
2026-03-15 11:42:25 +01:00
|
|
|
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-15 11:42:25 +01:00
|
|
|
joined_at TEXT NOT NULL DEFAULT (datetime('now'))
|
2026-03-14 21:31:03 +01:00
|
|
|
);
|
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id);
|
|
|
|
|
`)},
|
|
|
|
|
}
|
|
|
|
|
if err := db.MigrateFS(database, migrFS); err != nil {
|
|
|
|
|
t.Fatalf("MigrateFS: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return database
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedVoiceUser creates a user and returns its ID.
|
|
|
|
|
func seedVoiceUser(t *testing.T, database *db.DB, username string) int64 {
|
|
|
|
|
t.Helper()
|
2026-07-23 17:03:52 +02:00
|
|
|
id, err := database.CreateUser(context.Background(), username, "hash", 4)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("seedVoiceUser: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// seedVoiceChannel creates a voice-type channel and returns its ID.
|
|
|
|
|
func seedVoiceChannel(t *testing.T, database *db.DB, name string) int64 {
|
|
|
|
|
t.Helper()
|
2026-07-23 17:03:52 +02:00
|
|
|
id, err := database.CreateChannel(context.Background(), name, "voice", "", "", 0)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("seedVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return id
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── JoinVoiceChannel ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_JoinVoiceChannel_Success(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "alice")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "general-voice")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state == nil {
|
|
|
|
|
t.Fatal("GetVoiceState returned nil after join")
|
|
|
|
|
}
|
|
|
|
|
if state.UserID != userID {
|
|
|
|
|
t.Errorf("UserID = %d, want %d", state.UserID, userID)
|
|
|
|
|
}
|
|
|
|
|
if state.ChannelID != chanID {
|
|
|
|
|
t.Errorf("ChannelID = %d, want %d", state.ChannelID, chanID)
|
|
|
|
|
}
|
|
|
|
|
if state.Muted {
|
|
|
|
|
t.Error("Muted = true after join, want false")
|
|
|
|
|
}
|
|
|
|
|
if state.Deafened {
|
|
|
|
|
t.Error("Deafened = true after join, want false")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_JoinVoiceChannel_ReplacesExistingState(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "bob")
|
|
|
|
|
chan1 := seedVoiceChannel(t, database, "voice-1")
|
|
|
|
|
chan2 := seedVoiceChannel(t, database, "voice-2")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chan1); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("first JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// Join a different channel — should replace the old state.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chan2); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("second JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state == nil {
|
|
|
|
|
t.Fatal("GetVoiceState returned nil after re-join")
|
|
|
|
|
}
|
|
|
|
|
if state.ChannelID != chan2 {
|
|
|
|
|
t.Errorf("ChannelID = %d, want %d (new channel)", state.ChannelID, chan2)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_JoinVoiceChannel_SameChannel_Idempotent(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "carol")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-same")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("first join: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// Joining same channel again should not error.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("second join same channel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── LeaveVoiceChannel ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_LeaveVoiceChannel_ClearsState(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "dave")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-leave")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.LeaveVoiceChannel(context.Background(), userID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("LeaveVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState after leave: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state != nil {
|
|
|
|
|
t.Error("GetVoiceState returned non-nil after leave, want nil")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_LeaveVoiceChannel_NoState_NoError(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "eve")
|
|
|
|
|
|
|
|
|
|
// Leaving when not in any channel should not error.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.LeaveVoiceChannel(context.Background(), userID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("LeaveVoiceChannel (not in channel): %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── GetVoiceState ────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_GetVoiceState_NotFound(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "frank")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState(not found): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state != nil {
|
|
|
|
|
t.Error("GetVoiceState returned non-nil for user not in voice")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_GetVoiceState_IncludesUsername(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "grace")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-username")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state == nil {
|
|
|
|
|
t.Fatal("GetVoiceState returned nil")
|
|
|
|
|
}
|
|
|
|
|
if state.Username != "grace" {
|
|
|
|
|
t.Errorf("Username = %q, want %q", state.Username, "grace")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── GetChannelVoiceStates ────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_GetChannelVoiceStates_Empty(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "empty-voice")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
states, err := database.GetChannelVoiceStates(context.Background(), chanID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChannelVoiceStates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(states) != 0 {
|
|
|
|
|
t.Errorf("got %d states, want 0", len(states))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_GetChannelVoiceStates_MultipleUsers(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
u1 := seedVoiceUser(t, database, "henry")
|
|
|
|
|
u2 := seedVoiceUser(t, database, "iris")
|
|
|
|
|
u3 := seedVoiceUser(t, database, "jack")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "multi-voice")
|
|
|
|
|
otherChan := seedVoiceChannel(t, database, "other-voice")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("join u1: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u2, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("join u2: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// u3 joins a different channel — should not appear.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u3, otherChan); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("join u3: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
states, err := database.GetChannelVoiceStates(context.Background(), chanID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChannelVoiceStates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(states) != 2 {
|
|
|
|
|
t.Errorf("got %d states, want 2", len(states))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ids := map[int64]bool{u1: true, u2: true}
|
|
|
|
|
for _, s := range states {
|
|
|
|
|
if !ids[s.UserID] {
|
|
|
|
|
t.Errorf("unexpected user_id %d in channel states", s.UserID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── UpdateVoiceMute ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceMute_True(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "kate")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-mute")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("UpdateVoiceMute(true): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if state == nil || !state.Muted {
|
|
|
|
|
t.Error("Muted = false after UpdateVoiceMute(true)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceMute_False(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "leo")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-unmute")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("UpdateVoiceMute(true): %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceMute(context.Background(), userID, false); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("UpdateVoiceMute(false): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if state == nil || state.Muted {
|
|
|
|
|
t.Error("Muted = true after UpdateVoiceMute(false), want false")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceMute_NotInChannel_NoError(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "mia")
|
|
|
|
|
|
|
|
|
|
// Muting when not in a channel should not error.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceMute(context.Background(), userID, true); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("UpdateVoiceMute for non-member: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── UpdateVoiceDeafen ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceDeafen_True(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "noah")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-deafen")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceDeafen(context.Background(), userID, true); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("UpdateVoiceDeafen(true): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if state == nil || !state.Deafened {
|
|
|
|
|
t.Error("Deafened = false after UpdateVoiceDeafen(true)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceDeafen_False(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "olivia")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-undeafen")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceDeafen(context.Background(), userID, true); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("UpdateVoiceDeafen(true): %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceDeafen(context.Background(), userID, false); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("UpdateVoiceDeafen(false): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if state == nil || state.Deafened {
|
|
|
|
|
t.Error("Deafened = true after UpdateVoiceDeafen(false), want false")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── ClearVoiceState ──────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_ClearVoiceState_RemovesState(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "pedro")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-clear")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.ClearVoiceState(context.Background(), userID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("ClearVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState after clear: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state != nil {
|
|
|
|
|
t.Error("GetVoiceState returned non-nil after ClearVoiceState")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_ClearVoiceState_NotInChannel_NoError(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "quinn")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.ClearVoiceState(context.Background(), userID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("ClearVoiceState for non-member: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Cascade delete ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_GetChannelVoiceStates_IncludesUsername(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
u1 := seedVoiceUser(t, database, "rachel")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-name-check")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil {
|
2026-03-14 21:31:03 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
states, err := database.GetChannelVoiceStates(context.Background(), chanID)
|
2026-03-14 21:31:03 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChannelVoiceStates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(states) != 1 {
|
|
|
|
|
t.Fatalf("got %d states, want 1", len(states))
|
|
|
|
|
}
|
|
|
|
|
if states[0].Username != "rachel" {
|
|
|
|
|
t.Errorf("Username = %q, want %q", states[0].Username, "rachel")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-15 11:42:25 +01:00
|
|
|
|
|
|
|
|
// ─── UpdateVoiceCamera ────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceCamera_True(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "cam-on")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-camera")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceCamera(true): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if state == nil || !state.Camera {
|
|
|
|
|
t.Error("Camera = false after UpdateVoiceCamera(true)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceCamera_False(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "cam-off")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-camera-off")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceCamera(true): %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceCamera(context.Background(), userID, false); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceCamera(false): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if state == nil || state.Camera {
|
|
|
|
|
t.Error("Camera = true after UpdateVoiceCamera(false), want false")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceCamera_NotInChannel_NoError(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "cam-noop")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceCamera for non-member: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── UpdateVoiceScreenshare ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceScreenshare_True(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "share-on")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-screen")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceScreenshare(true): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if state == nil || !state.Screenshare {
|
|
|
|
|
t.Error("Screenshare = false after UpdateVoiceScreenshare(true)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_UpdateVoiceScreenshare_False(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "share-off")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-screen-off")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceScreenshare(true): %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceScreenshare(context.Background(), userID, false); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceScreenshare(false): %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if state == nil || state.Screenshare {
|
|
|
|
|
t.Error("Screenshare = true after UpdateVoiceScreenshare(false), want false")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── CountChannelVoiceUsers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_CountChannelVoiceUsers_Empty(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "count-empty")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
count, err := database.CountChannelVoiceUsers(context.Background(), chanID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CountChannelVoiceUsers: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if count != 0 {
|
|
|
|
|
t.Errorf("count = %d, want 0", count)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_CountChannelVoiceUsers_Multiple(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
u1 := seedVoiceUser(t, database, "count1")
|
|
|
|
|
u2 := seedVoiceUser(t, database, "count2")
|
|
|
|
|
u3 := seedVoiceUser(t, database, "count3")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "count-multi")
|
|
|
|
|
otherChan := seedVoiceChannel(t, database, "count-other")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u1, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("join u1: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u2, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("join u2: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// u3 joins a different channel — should not be counted.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u3, otherChan); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("join u3: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
count, err := database.CountChannelVoiceUsers(context.Background(), chanID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CountChannelVoiceUsers: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if count != 2 {
|
|
|
|
|
t.Errorf("count = %d, want 2", count)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── ClearAllVoiceStates ─────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_ClearAllVoiceStates_RemovesAll(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
u1 := seedVoiceUser(t, database, "clear1")
|
|
|
|
|
u2 := seedVoiceUser(t, database, "clear2")
|
|
|
|
|
chan1 := seedVoiceChannel(t, database, "clear-ch1")
|
|
|
|
|
chan2 := seedVoiceChannel(t, database, "clear-ch2")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u1, chan1); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("join u1: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), u2, chan2); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("join u2: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.ClearAllVoiceStates(context.Background()); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("ClearAllVoiceStates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
s1, _ := database.GetVoiceState(context.Background(), u1)
|
|
|
|
|
s2, _ := database.GetVoiceState(context.Background(), u2)
|
2026-03-15 11:42:25 +01:00
|
|
|
if s1 != nil || s2 != nil {
|
|
|
|
|
t.Error("voice states still exist after ClearAllVoiceStates")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_ClearAllVoiceStates_EmptyTable_NoError(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.ClearAllVoiceStates(context.Background()); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("ClearAllVoiceStates on empty table: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── JoinVoiceChannel resets camera/screenshare ──────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_JoinVoiceChannel_ResetsCameraAndScreenshare(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "reset-av")
|
|
|
|
|
chan1 := seedVoiceChannel(t, database, "voice-reset1")
|
|
|
|
|
chan2 := seedVoiceChannel(t, database, "voice-reset2")
|
|
|
|
|
|
|
|
|
|
// Join, enable camera and screenshare.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chan1); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("first join: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceCamera(context.Background(), userID, true); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceCamera: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.UpdateVoiceScreenshare(context.Background(), userID, true); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("UpdateVoiceScreenshare: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Join a different channel — camera and screenshare should be reset.
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chan2); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("second join: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if state == nil {
|
|
|
|
|
t.Fatal("GetVoiceState returned nil after re-join")
|
|
|
|
|
}
|
|
|
|
|
if state.Camera {
|
|
|
|
|
t.Error("Camera should be reset to false on re-join")
|
|
|
|
|
}
|
|
|
|
|
if state.Screenshare {
|
|
|
|
|
t.Error("Screenshare should be reset to false on re-join")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Camera/Screenshare in GetVoiceState ─────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_GetVoiceState_IncludesCameraAndScreenshare(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "av-fields")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-av-fields")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Initially both should be false.
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ := database.GetVoiceState(context.Background(), userID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if state == nil {
|
|
|
|
|
t.Fatal("GetVoiceState returned nil")
|
|
|
|
|
}
|
|
|
|
|
if state.Camera {
|
|
|
|
|
t.Error("Camera should be false after join")
|
|
|
|
|
}
|
|
|
|
|
if state.Screenshare {
|
|
|
|
|
t.Error("Screenshare should be false after join")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Enable both.
|
2026-07-23 17:03:52 +02:00
|
|
|
_ = database.UpdateVoiceCamera(context.Background(), userID, true)
|
|
|
|
|
_ = database.UpdateVoiceScreenshare(context.Background(), userID, true)
|
2026-03-15 11:42:25 +01:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
state, _ = database.GetVoiceState(context.Background(), userID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if state == nil {
|
|
|
|
|
t.Fatal("GetVoiceState returned nil after update")
|
|
|
|
|
}
|
|
|
|
|
if !state.Camera {
|
|
|
|
|
t.Error("Camera should be true after UpdateVoiceCamera(true)")
|
|
|
|
|
}
|
|
|
|
|
if !state.Screenshare {
|
|
|
|
|
t.Error("Screenshare should be true after UpdateVoiceScreenshare(true)")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Camera/Screenshare in GetChannelVoiceStates ─────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestVoice_GetChannelVoiceStates_IncludesCameraAndScreenshare(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "chan-av")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-chan-av")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-15 11:42:25 +01:00
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
_ = database.UpdateVoiceCamera(context.Background(), userID, true)
|
2026-03-15 11:42:25 +01:00
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
states, err := database.GetChannelVoiceStates(context.Background(), chanID)
|
2026-03-15 11:42:25 +01:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetChannelVoiceStates: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if len(states) != 1 {
|
|
|
|
|
t.Fatalf("got %d states, want 1", len(states))
|
|
|
|
|
}
|
|
|
|
|
if !states[0].Camera {
|
|
|
|
|
t.Error("Camera should be true in GetChannelVoiceStates")
|
|
|
|
|
}
|
|
|
|
|
if states[0].Screenshare {
|
|
|
|
|
t.Error("Screenshare should be false in GetChannelVoiceStates")
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-31 11:41:59 +02:00
|
|
|
|
|
|
|
|
func TestVoice_JoinVoiceChannel_SameChannel_RefreshesJoinToken(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "same-channel-token")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-same-token")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-31 11:41:59 +02:00
|
|
|
t.Fatalf("first JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
first, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-31 11:41:59 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState(first): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if first == nil || first.JoinedAt == "" {
|
|
|
|
|
t.Fatal("first join token missing")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-31 11:41:59 +02:00
|
|
|
t.Fatalf("second JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
second, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-31 11:41:59 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState(second): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if second == nil || second.JoinedAt == "" {
|
|
|
|
|
t.Fatal("second join token missing")
|
|
|
|
|
}
|
|
|
|
|
if second.JoinedAt == first.JoinedAt {
|
|
|
|
|
t.Fatalf("same-channel rejoin reused join token %q", second.JoinedAt)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_LeaveVoiceChannelIfMatch_DoesNotDeleteSameChannelRejoin(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
userID := seedVoiceUser(t, database, "stale-delete")
|
|
|
|
|
chanID := seedVoiceChannel(t, database, "voice-stale-delete")
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-31 11:41:59 +02:00
|
|
|
t.Fatalf("first JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
first, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-31 11:41:59 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState(first): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if first == nil {
|
|
|
|
|
t.Fatal("GetVoiceState(first) returned nil")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
if err := database.JoinVoiceChannel(context.Background(), userID, chanID); err != nil {
|
2026-03-31 11:41:59 +02:00
|
|
|
t.Fatalf("second JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
2026-07-23 17:03:52 +02:00
|
|
|
second, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-31 11:41:59 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState(second): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if second == nil {
|
|
|
|
|
t.Fatal("GetVoiceState(second) returned nil")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
deleted, err := database.LeaveVoiceChannelIfMatch(context.Background(), userID, chanID, first.JoinedAt)
|
2026-03-31 11:41:59 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("LeaveVoiceChannelIfMatch: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if deleted {
|
|
|
|
|
t.Fatal("stale join token deleted the replacement same-channel row")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-23 17:03:52 +02:00
|
|
|
current, err := database.GetVoiceState(context.Background(), userID)
|
2026-03-31 11:41:59 +02:00
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("GetVoiceState(current): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if current == nil {
|
|
|
|
|
t.Fatal("replacement voice state was removed")
|
|
|
|
|
}
|
|
|
|
|
if current.JoinedAt != second.JoinedAt {
|
|
|
|
|
t.Fatalf("replacement join token = %q, want %q", current.JoinedAt, second.JoinedAt)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-15 12:57:51 +02:00
|
|
|
|
|
|
|
|
// ─── SetVoiceServerMute / SetVoiceServerDeafen scoping (OC-0005) ────────────
|
|
|
|
|
//
|
|
|
|
|
// ApplyVoiceServerMute/ClearVoiceServerMute and their deafen equivalents
|
|
|
|
|
// match on `WHERE user_id = ?` alone. A moderator's mute/deafen command is
|
|
|
|
|
// authorized against a channel snapshot (voiceModTarget + requireTargetInChannel
|
|
|
|
|
// in ws/voice_moderation.go), but the DB write that follows several round
|
|
|
|
|
// trips later is not scoped to that channel: if the target's voice_states row
|
|
|
|
|
// has since moved to a different channel — including a DM call the moderator
|
|
|
|
|
// was never authorized against — the unscoped write still lands on it.
|
|
|
|
|
|
|
|
|
|
func TestVoice_SetVoiceServerMute_ScopedToChannel(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
userID := seedVoiceUser(t, database, "scope-mute-user")
|
|
|
|
|
chanA := seedVoiceChannel(t, database, "vc-scope-mute-a")
|
|
|
|
|
chanB := seedVoiceChannel(t, database, "vc-scope-mute-b")
|
|
|
|
|
|
|
|
|
|
if err := database.JoinVoiceChannel(ctx, userID, chanA); err != nil {
|
|
|
|
|
t.Fatalf("JoinVoiceChannel A: %v", err)
|
|
|
|
|
}
|
|
|
|
|
// Simulate the race: the user's row moves to channel B — a channel nobody's
|
|
|
|
|
// mute command was authorized against — before the write below lands.
|
|
|
|
|
if err := database.JoinVoiceChannel(ctx, userID, chanB); err != nil {
|
|
|
|
|
t.Fatalf("JoinVoiceChannel B: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A mute authorized against chanA (the channel a stale requireTargetInChannel
|
|
|
|
|
// snapshot showed) must not land on the row now in chanB.
|
|
|
|
|
matched, err := database.SetVoiceServerMute(ctx, userID, chanA, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("SetVoiceServerMute: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if matched {
|
|
|
|
|
t.Error("SetVoiceServerMute matched=true against channel A after the user moved to channel B")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state, err := database.GetVoiceState(ctx, userID)
|
|
|
|
|
if err != nil || state == nil {
|
|
|
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state.ChannelID != chanB {
|
|
|
|
|
t.Fatalf("test setup broken: want user in channel B, got %d", state.ChannelID)
|
|
|
|
|
}
|
|
|
|
|
if state.ServerMuted {
|
|
|
|
|
t.Error("ServerMuted = true, want false: an unscoped write must not follow the user to a channel nobody authorized the mute against")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The scoped write must still succeed when the channel does match.
|
|
|
|
|
matched, err = database.SetVoiceServerMute(ctx, userID, chanB, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("SetVoiceServerMute (matching channel): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !matched {
|
|
|
|
|
t.Error("SetVoiceServerMute matched=false for the channel the user is actually in")
|
|
|
|
|
}
|
|
|
|
|
state, err = database.GetVoiceState(ctx, userID)
|
|
|
|
|
if err != nil || state == nil {
|
|
|
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !state.ServerMuted {
|
|
|
|
|
t.Error("ServerMuted = false, want true: a scoped write against the correct channel must still apply")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestVoice_SetVoiceServerDeafen_ScopedToChannel(t *testing.T) {
|
|
|
|
|
database := newVoiceTestDB(t)
|
|
|
|
|
ctx := context.Background()
|
|
|
|
|
userID := seedVoiceUser(t, database, "scope-deafen-user")
|
|
|
|
|
chanA := seedVoiceChannel(t, database, "vc-scope-deafen-a")
|
|
|
|
|
chanB := seedVoiceChannel(t, database, "vc-scope-deafen-b")
|
|
|
|
|
|
|
|
|
|
if err := database.JoinVoiceChannel(ctx, userID, chanA); err != nil {
|
|
|
|
|
t.Fatalf("JoinVoiceChannel A: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := database.JoinVoiceChannel(ctx, userID, chanB); err != nil {
|
|
|
|
|
t.Fatalf("JoinVoiceChannel B: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
matched, err := database.SetVoiceServerDeafen(ctx, userID, chanA, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("SetVoiceServerDeafen: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if matched {
|
|
|
|
|
t.Error("SetVoiceServerDeafen matched=true against channel A after the user moved to channel B")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state, err := database.GetVoiceState(ctx, userID)
|
|
|
|
|
if err != nil || state == nil {
|
|
|
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if state.ChannelID != chanB {
|
|
|
|
|
t.Fatalf("test setup broken: want user in channel B, got %d", state.ChannelID)
|
|
|
|
|
}
|
|
|
|
|
if state.ServerDeafened {
|
|
|
|
|
t.Error("ServerDeafened = true, want false: an unscoped write must not follow the user to a channel nobody authorized the deafen against")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
matched, err = database.SetVoiceServerDeafen(ctx, userID, chanB, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("SetVoiceServerDeafen (matching channel): %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !matched {
|
|
|
|
|
t.Error("SetVoiceServerDeafen matched=false for the channel the user is actually in")
|
|
|
|
|
}
|
|
|
|
|
state, err = database.GetVoiceState(ctx, userID)
|
|
|
|
|
if err != nil || state == nil {
|
|
|
|
|
t.Fatalf("GetVoiceState: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if !state.ServerDeafened {
|
|
|
|
|
t.Error("ServerDeafened = false, want true: a scoped write against the correct channel must still apply")
|
|
|
|
|
}
|
|
|
|
|
}
|