2026-07-31 15:41:57 +02:00
|
|
|
package ws_test
|
|
|
|
|
|
|
|
|
|
// coverage_misc_test.go: client state, message builders, hub lifecycle,
|
|
|
|
|
// ping, buildReady, presence/focus/typing, attachments, permissions,
|
|
|
|
|
// broadcast, and webhook coverage tests (split from coverage_boost_test.go).
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"math"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-08-28 06:54:32 +02:00
|
|
|
"github.com/J3vb/OwnCord/Server/ws"
|
2026-07-31 15:41:57 +02:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// ─── SetClientVoiceChID stores the tracked voice channel ─────────────────────
|
|
|
|
|
|
|
|
|
|
func TestSetClientVoiceChID_SetsValue(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
ws.SetClientVoiceChID(c, 42)
|
|
|
|
|
if got := ws.GetClientVoiceChIDForTest(c); got != 42 {
|
|
|
|
|
t.Fatalf("voiceChID = %d, want 42", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetClientVoiceChID_ZeroClearsVoice(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
ws.SetClientVoiceChID(c, 100)
|
|
|
|
|
ws.SetClientVoiceChID(c, 0)
|
|
|
|
|
if got := ws.GetClientVoiceChIDForTest(c); got != 0 {
|
|
|
|
|
t.Fatalf("voiceChID = %d, want 0", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSetClientVoiceChID_LastWriteWins(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
ws.SetClientVoiceChID(c, 7)
|
|
|
|
|
ws.SetClientVoiceChID(c, 99)
|
|
|
|
|
if got := ws.GetClientVoiceChIDForTest(c); got != 99 {
|
|
|
|
|
t.Fatalf("voiceChID = %d, want 99", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── buildJSON error fallback (messages.go:18 — 75% coverage) ────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildJSON_UnmarshalableValue_ReturnsFallback(t *testing.T) {
|
|
|
|
|
// math.Inf is not valid JSON — forces the error path in buildJSON.
|
|
|
|
|
out := ws.BuildJSONForTest(math.Inf(1))
|
|
|
|
|
if !json.Valid(out) {
|
|
|
|
|
t.Fatalf("fallback output is not valid JSON: %s", out)
|
|
|
|
|
}
|
|
|
|
|
var m map[string]string
|
|
|
|
|
if err := json.Unmarshal(out, &m); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal fallback: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if m["type"] != "error" {
|
|
|
|
|
t.Errorf("fallback type = %q, want error", m["type"])
|
|
|
|
|
}
|
|
|
|
|
if m["message"] != "internal marshal error" {
|
|
|
|
|
t.Errorf("fallback message = %q, want 'internal marshal error'", m["message"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildJSON_ChannelValue_ReturnsFallback(t *testing.T) {
|
|
|
|
|
// Channels are not JSON-marshalable.
|
|
|
|
|
out := ws.BuildJSONForTest(make(chan int))
|
|
|
|
|
if !json.Valid(out) {
|
|
|
|
|
t.Fatalf("fallback output is not valid JSON: %s", out)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── GracefulStop with clients having voice state (hub.go:188 — 75%) ─────────
|
|
|
|
|
|
|
|
|
|
func TestGracefulStop_WithClientsHavingVoiceState(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
|
|
|
|
|
user := seedCoverageOwner(t, database, "graceful-voice-user")
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
// Set voice channel ID on the client to simulate voice state.
|
|
|
|
|
ws.SetClientVoiceChID(c, 42)
|
|
|
|
|
|
|
|
|
|
if count := hub.ClientCount(); count != 1 {
|
|
|
|
|
t.Fatalf("before GracefulStop: client count = %d, want 1", count)
|
|
|
|
|
}
|
|
|
|
|
if got := ws.GetClientVoiceChIDForTest(c); got != 42 {
|
|
|
|
|
t.Fatalf("voiceChID before stop = %d, want 42", got)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GracefulStop is synchronous — returning at all proves no deadlock.
|
|
|
|
|
hub.GracefulStop()
|
|
|
|
|
// GracefulStop signals clients to close — test clients don't have real
|
|
|
|
|
// goroutines so they won't self-unregister, but verify the hub accepted
|
|
|
|
|
// the stop without deadlocking on voice-state cleanup.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGracefulStop_MultipleClients(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
|
|
|
|
|
for i := range 5 {
|
|
|
|
|
user := seedCoverageOwner(t, database, "graceful-multi-"+string(rune('a'+i)))
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
}
|
|
|
|
|
waitClientCount(t, hub, 5)
|
|
|
|
|
|
|
|
|
|
if count := hub.ClientCount(); count != 5 {
|
|
|
|
|
t.Fatalf("before GracefulStop: client count = %d, want 5", count)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// GracefulStop is synchronous — returning at all proves no deadlock on
|
|
|
|
|
// multiple clients.
|
|
|
|
|
hub.GracefulStop()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Ping message type (handlers.go — pong response) ─────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestHandleMessage_Ping_ReturnsPong(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "ping-user")
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{"type": "ping"})
|
|
|
|
|
// The pong reply is sent synchronously by handleMessage.
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
select {
|
|
|
|
|
case msg := <-send:
|
|
|
|
|
var env map[string]any
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env["type"] != "pong" {
|
|
|
|
|
t.Errorf("type = %q, want pong", env["type"])
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(500 * time.Millisecond):
|
|
|
|
|
t.Error("expected pong response")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── buildReady with voice channel having participants ────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_VoiceChannelWithParticipants(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "ready-voice-user")
|
|
|
|
|
role, rErr := database.GetRoleByID(context.Background(), 1)
|
|
|
|
|
if rErr != nil || role == nil {
|
|
|
|
|
t.Fatalf("GetRoleByID: %v", rErr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create a voice channel.
|
|
|
|
|
vcID, err := database.CreateChannel(context.Background(), "voice-room", "voice", "", "", 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel voice: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create another user and join them to voice.
|
|
|
|
|
other := seedCoverageOwner(t, database, "ready-voice-other")
|
|
|
|
|
if err := database.JoinVoiceChannel(context.Background(), other.ID, vcID); err != nil {
|
|
|
|
|
t.Fatalf("JoinVoiceChannel: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
VoiceStates []struct {
|
|
|
|
|
ChannelID float64 `json:"channel_id"`
|
|
|
|
|
UserID float64 `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) != 1 {
|
|
|
|
|
t.Errorf("voice_states count = %d, want 1", len(env.Payload.VoiceStates))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildReady_MultipleChannelTypes(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "ready-multi-user")
|
|
|
|
|
role, rErr := database.GetRoleByID(context.Background(), 1)
|
|
|
|
|
if rErr != nil || role == nil {
|
|
|
|
|
t.Fatalf("GetRoleByID: %v", rErr)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Create text and voice channels.
|
|
|
|
|
_, err := database.CreateChannel(context.Background(), "text-chan", "text", "General", "", 0)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel text: %v", err)
|
|
|
|
|
}
|
|
|
|
|
_, err = database.CreateChannel(context.Background(), "voice-chan", "voice", "General", "", 1)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateChannel voice: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg, err := hub.BuildReadyWithRoleForTest(database, user.ID, role)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("BuildReadyWithRoleForTest: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
if len(env.Payload.Channels) != 2 {
|
|
|
|
|
t.Errorf("channels count = %d, want 2", len(env.Payload.Channels))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Text channels should have unread_count; voice channels should not.
|
|
|
|
|
for _, ch := range env.Payload.Channels {
|
|
|
|
|
if ch["type"] == "text" {
|
|
|
|
|
if _, ok := ch["unread_count"]; !ok {
|
|
|
|
|
t.Error("text channel missing unread_count")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── channel_focus handler ───────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestHandleChannelFocus_InvalidChannelID(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "cf-bad-chid")
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "channel_focus",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"channel_id": "not-a-number",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
// V2 CommandConstructor rejects non-numeric channel_id with BAD_REQUEST.
|
|
|
|
|
code := drainForErrorCode(send, 100*time.Millisecond)
|
|
|
|
|
if code != "BAD_REQUEST" {
|
|
|
|
|
t.Fatalf("expected BAD_REQUEST for non-numeric channel_id, got code=%q", code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleChannelFocus_ValidChannel(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "cf-valid")
|
|
|
|
|
chID := seedTestChannel(t, database, "cf-valid-chan")
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "channel_focus",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"channel_id": chID,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
// Valid channel focus should not produce an error message.
|
|
|
|
|
code := drainForErrorCode(send, 100*time.Millisecond)
|
|
|
|
|
if code != "" {
|
|
|
|
|
t.Errorf("expected no error for valid channel_focus, got code=%q", code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── presence handler error paths ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestHandlePresence_InvalidStatus(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "pres-bad-status")
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "presence_update",
|
|
|
|
|
"payload": map[string]any{
|
2026-08-01 22:06:14 +02:00
|
|
|
"status": "afk", // not a protocol status (invisible IS one since phase 6)
|
2026-07-31 15:41:57 +02:00
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
code := drainForErrorCode(send, 200*time.Millisecond)
|
|
|
|
|
if code != "BAD_REQUEST" {
|
|
|
|
|
t.Errorf("error code = %q, want BAD_REQUEST for invalid status", code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandlePresence_InvalidPayload(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "pres-bad-payload")
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "presence_update",
|
|
|
|
|
"payload": "not-an-object",
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
code := drainForErrorCode(send, 200*time.Millisecond)
|
|
|
|
|
if code != "BAD_REQUEST" {
|
|
|
|
|
t.Errorf("error code = %q, want BAD_REQUEST for invalid presence payload", code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── typing handler error path ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestHandleTyping_InvalidChannelID(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "typing-bad-chid")
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "typing_start",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"channel_id": -1,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
code := drainForErrorCode(send, 200*time.Millisecond)
|
|
|
|
|
if code != "BAD_REQUEST" {
|
|
|
|
|
t.Errorf("error code = %q, want BAD_REQUEST for invalid typing channel_id", code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── message builder coverage ────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestBuildPresenceMsg_ValidJSON(t *testing.T) {
|
|
|
|
|
msg := ws.BuildJSONForTest(map[string]any{
|
|
|
|
|
"type": "presence",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"user_id": 1,
|
|
|
|
|
"status": "online",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if !json.Valid(msg) {
|
|
|
|
|
t.Error("buildPresenceMsg output is not valid JSON")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildChatSendOK_ValidJSON(t *testing.T) {
|
|
|
|
|
msg := ws.BuildJSONForTest(map[string]any{
|
|
|
|
|
"type": "chat_send_ok",
|
|
|
|
|
"id": "req-1",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"message_id": 1,
|
|
|
|
|
"timestamp": "2024-01-01T00:00:00Z",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
if !json.Valid(msg) {
|
|
|
|
|
t.Error("buildChatSendOK output is not valid JSON")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── SendToUser full buffer path (hub.go:308 — 87.5%) ───────────────────────
|
|
|
|
|
|
|
|
|
|
func TestSendToUser_FullBuffer_ReturnsFalse(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "send-full-user")
|
|
|
|
|
// Create a send channel with buffer size 1.
|
|
|
|
|
send := make(chan []byte, 1)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
// Fill the buffer.
|
|
|
|
|
send <- []byte(`{"type":"filler"}`)
|
|
|
|
|
|
|
|
|
|
// Next send should return false (buffer full).
|
|
|
|
|
ok := hub.SendToUser(user.ID, []byte(`{"type":"overflow"}`))
|
|
|
|
|
if ok {
|
|
|
|
|
t.Error("SendToUser should return false when send buffer is full")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── handleChatSend with attachments (handlers.go:127 — 76.2%) ──────────────
|
|
|
|
|
|
|
|
|
|
func TestHandleChatSend_WithAttachments_NoPermission(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
// Use a member user.
|
|
|
|
|
_, err := database.CreateUser(context.Background(), "attach-noperm-user", "hash", 4)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("CreateUser: %v", err)
|
|
|
|
|
}
|
|
|
|
|
user, err := database.GetUserByUsername(context.Background(), "attach-noperm-user")
|
|
|
|
|
if err != nil || user == nil {
|
|
|
|
|
t.Fatalf("GetUserByUsername: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
chID := seedTestChannel(t, database, "attach-noperm-chan")
|
|
|
|
|
|
|
|
|
|
// Deny ATTACH_FILES (0x0020) on this channel for Member role (id=4).
|
|
|
|
|
_, err = database.ExecContext(context.Background(), "INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, 4, 0, 32)", chID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("INSERT channel_overrides: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
send := make(chan []byte, 32)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, chID, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "chat_send",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"channel_id": chID,
|
|
|
|
|
"content": "msg with attachment",
|
|
|
|
|
"attachments": []string{"att-id-1"},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
code := drainForErrorCode(send, 200*time.Millisecond)
|
|
|
|
|
if code != "FORBIDDEN" {
|
|
|
|
|
t.Errorf("error code = %q, want FORBIDDEN for denied ATTACH_FILES permission", code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestHandleChatSend_WithAttachments_Success(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "attach-ok-user")
|
|
|
|
|
chID := seedTestChannel(t, database, "attach-ok-chan")
|
|
|
|
|
send := make(chan []byte, 32)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, chID, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "chat_send",
|
|
|
|
|
"id": "attach-req",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"channel_id": chID,
|
|
|
|
|
"content": "msg with attachment",
|
|
|
|
|
"attachments": []string{"nonexistent-att-id"},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
// Should still succeed (attachments that don't exist are silently skipped).
|
|
|
|
|
msgs := drainChanTimeout(send, 300*time.Millisecond)
|
|
|
|
|
found := false
|
|
|
|
|
for _, msg := range msgs {
|
|
|
|
|
var env map[string]any
|
|
|
|
|
if json.Unmarshal(msg, &env) == nil && env["type"] == "chat_send_ok" {
|
|
|
|
|
found = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
t.Error("expected chat_send_ok even with nonexistent attachment IDs")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── hasChannelPerm with nil user (handlers.go:454) ──────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestHasChannelPerm_NilUser_DeniesPermission(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
chID := seedTestChannel(t, database, "perm-nil-user-chan")
|
|
|
|
|
send := make(chan []byte, 16)
|
|
|
|
|
// Create a test client WITHOUT a user (user == nil).
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
// Try to send a chat message — should get FORBIDDEN due to nil user.
|
|
|
|
|
raw, _ := json.Marshal(map[string]any{
|
|
|
|
|
"type": "chat_send",
|
|
|
|
|
"payload": map[string]any{
|
|
|
|
|
"channel_id": chID,
|
|
|
|
|
"content": "should fail",
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
hub.HandleMessageForTest(c, raw)
|
|
|
|
|
|
|
|
|
|
code := drainForErrorCode(send, 200*time.Millisecond)
|
|
|
|
|
if code != "FORBIDDEN" {
|
|
|
|
|
t.Errorf("error code = %q, want FORBIDDEN for nil user", code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── deliverBroadcast with full send buffer (hub.go:344) ─────────────────────
|
|
|
|
|
|
|
|
|
|
func TestDeliverBroadcast_FullBuffer_DropsMessage(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "bcast-full-user")
|
|
|
|
|
// Create a tiny send buffer.
|
|
|
|
|
send := make(chan []byte, 1)
|
|
|
|
|
c := ws.NewTestClientWithUser(hub, user, 0, send)
|
|
|
|
|
hub.Register(c)
|
|
|
|
|
waitRegistered(t, hub, c)
|
|
|
|
|
|
|
|
|
|
// Fill the buffer.
|
|
|
|
|
send <- []byte(`{"type":"filler"}`)
|
|
|
|
|
|
|
|
|
|
// Broadcasting should not block — message dropped.
|
|
|
|
|
hub.BroadcastToAll([]byte(`{"type":"should_be_dropped"}`))
|
|
|
|
|
// Absence assertion: bounded window for the hub loop to (wrongly) enqueue
|
|
|
|
|
// the dropped message before checking the buffer is unchanged.
|
|
|
|
|
time.Sleep(50 * time.Millisecond)
|
|
|
|
|
|
|
|
|
|
// Buffer should still contain only the filler message (dropped msg was not enqueued).
|
|
|
|
|
if len(send) != 1 {
|
|
|
|
|
t.Errorf("send buffer length = %d, want 1 (dropped message should not be enqueued)", len(send))
|
|
|
|
|
}
|
|
|
|
|
// The client should still be registered despite the dropped message.
|
|
|
|
|
if !hub.IsUserConnected(user.ID) {
|
|
|
|
|
t.Error("client should remain connected after a dropped broadcast")
|
|
|
|
|
}
|
|
|
|
|
_ = c // keep c referenced
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBuildAuthOK_NonNilAvatar(t *testing.T) {
|
|
|
|
|
hub, database := newCoverageHub(t)
|
|
|
|
|
user := seedCoverageOwner(t, database, "authok-avatar-user")
|
|
|
|
|
// Set a non-nil avatar.
|
|
|
|
|
_, err := database.ExecContext(context.Background(), "UPDATE users SET avatar = 'https://example.com/pic.png' WHERE id = ?", user.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("UPDATE avatar: %v", err)
|
|
|
|
|
}
|
|
|
|
|
user, err = database.GetUserByUsername(context.Background(), "authok-avatar-user")
|
|
|
|
|
if err != nil || user == nil {
|
|
|
|
|
t.Fatalf("GetUserByUsername: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
msg := hub.BuildAuthOKForTest(user, "owner")
|
|
|
|
|
var env struct {
|
|
|
|
|
Payload struct {
|
|
|
|
|
User struct {
|
|
|
|
|
Avatar string `json:"avatar"`
|
|
|
|
|
} `json:"user"`
|
|
|
|
|
} `json:"payload"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.Unmarshal(msg, &env); err != nil {
|
|
|
|
|
t.Fatalf("unmarshal: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if env.Payload.User.Avatar != "https://example.com/pic.png" {
|
|
|
|
|
t.Errorf("avatar = %q, want https://example.com/pic.png", env.Payload.User.Avatar)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Webhook parse helpers ──────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestWebhookParseIdentity_Valid(t *testing.T) {
|
|
|
|
|
id, err := ws.ParseIdentityForTest("user-42")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if id != 42 {
|
|
|
|
|
t.Errorf("id = %d, want 42", id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWebhookParseIdentity_Invalid(t *testing.T) {
|
|
|
|
|
_, err := ws.ParseIdentityForTest("invalid")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error for invalid identity, got nil")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWebhookParseRoomChannelID_Valid(t *testing.T) {
|
|
|
|
|
id, err := ws.ParseRoomChannelIDForTest("channel-5")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("unexpected error: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if id != 5 {
|
|
|
|
|
t.Errorf("id = %d, want 5", id)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestWebhookParseRoomChannelID_Invalid(t *testing.T) {
|
|
|
|
|
_, err := ws.ParseRoomChannelIDForTest("bad")
|
|
|
|
|
if err == nil {
|
|
|
|
|
t.Fatal("expected error for invalid room name, got nil")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── getLastActivity (client.go:153) ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestGetLastActivity_ReturnsZeroForNewTestClient(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
got := ws.GetLastActivityForTest(c)
|
|
|
|
|
if !got.IsZero() {
|
|
|
|
|
t.Fatalf("expected zero time for new test client, got %v", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetLastActivity_UpdatedByTouch(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
before := time.Now()
|
|
|
|
|
ws.TouchForTest(c)
|
|
|
|
|
after := time.Now()
|
|
|
|
|
|
|
|
|
|
got := ws.GetLastActivityForTest(c)
|
|
|
|
|
if got.Before(before) || got.After(after) {
|
|
|
|
|
t.Fatalf("lastActivity = %v, expected between %v and %v", got, before, after)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestGetLastActivity_MultipleTouch(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
ws.TouchForTest(c)
|
|
|
|
|
first := ws.GetLastActivityForTest(c)
|
|
|
|
|
|
|
|
|
|
time.Sleep(5 * time.Millisecond)
|
|
|
|
|
ws.TouchForTest(c)
|
|
|
|
|
second := ws.GetLastActivityForTest(c)
|
|
|
|
|
|
|
|
|
|
if !second.After(first) {
|
|
|
|
|
t.Fatalf("second touch (%v) should be after first (%v)", second, first)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── clearVoiceChID (client.go:203) ─────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestClearVoiceChID_ReturnsOldValueAndClearsToZero(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
ws.SetVoiceChIDForTest(c, 42)
|
|
|
|
|
old := ws.ClearVoiceChIDForTest(c)
|
|
|
|
|
if old != 42 {
|
|
|
|
|
t.Fatalf("clearVoiceChID returned %d, want 42", old)
|
|
|
|
|
}
|
|
|
|
|
if got := ws.GetClientVoiceChIDForTest(c); got != 0 {
|
|
|
|
|
t.Fatalf("voiceChID after clear = %d, want 0", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClearVoiceChID_ReturnsZeroWhenNotInVoice(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
old := ws.ClearVoiceChIDForTest(c)
|
|
|
|
|
if old != 0 {
|
|
|
|
|
t.Fatalf("clearVoiceChID returned %d, want 0", old)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClearVoiceChID_DoubleClearReturnsZero(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
send := make(chan []byte, 4)
|
|
|
|
|
c := ws.NewTestClient(hub, 1, send)
|
|
|
|
|
|
|
|
|
|
ws.SetVoiceChIDForTest(c, 99)
|
|
|
|
|
first := ws.ClearVoiceChIDForTest(c)
|
|
|
|
|
second := ws.ClearVoiceChIDForTest(c)
|
|
|
|
|
if first != 99 {
|
|
|
|
|
t.Fatalf("first clear = %d, want 99", first)
|
|
|
|
|
}
|
|
|
|
|
if second != 0 {
|
|
|
|
|
t.Fatalf("second clear = %d, want 0", second)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── BroadcastToChannel / BroadcastToAll full-channel path ──────────────────
|
|
|
|
|
|
|
|
|
|
func TestBroadcastToChannel_DropsWhenFull(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
// Don't start Run() — broadcast channel will fill up.
|
|
|
|
|
// The broadcast channel capacity is 256.
|
|
|
|
|
for range 260 {
|
|
|
|
|
hub.BroadcastToChannel(1, []byte(`{"type":"test"}`))
|
|
|
|
|
}
|
|
|
|
|
// With no Run() loop draining, some messages are dropped.
|
|
|
|
|
// Hub should still be functional after overflow — verify by checking
|
|
|
|
|
// that a user lookup still works (hub internals not corrupted).
|
|
|
|
|
if hub.IsUserConnected(9999) {
|
|
|
|
|
t.Error("expected false for non-existent user after broadcast overflow")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestBroadcastToAll_DropsWhenFull(t *testing.T) {
|
|
|
|
|
hub, _ := newCoverageHub(t)
|
|
|
|
|
for range 260 {
|
|
|
|
|
hub.BroadcastToAll([]byte(`{"type":"test"}`))
|
|
|
|
|
}
|
|
|
|
|
// Hub should still be functional after overflow — verify hub state is intact.
|
|
|
|
|
if hub.IsUserConnected(9999) {
|
|
|
|
|
t.Error("expected false for non-existent user after broadcast overflow")
|
|
|
|
|
}
|
|
|
|
|
}
|