Files
OwnCord/Server/ws/handlers_command_test.go
T
J3vbandClaude Fable 5 8787b9066d fix: batch of 34 correctness fixes across server and client (#1372)
* fix(client): 3 defect(s) (OC-0037, OC-0063, OC-0116)

Route the tray Status submenu through saveUserStatus() (mapping the legacy
"offline" to "invisible") so notifications, autoIdle, and reconnect presence
restore all agree with the tray's choice; build the connected overlay from
the auth_ok payload instead of a pre-dispatch authStore snapshot; keep the
TOTP overlay open across a rejected verify (totpPending latch) and retain
the partial token for the retry instead of clearing it in finally.

Hand-applied combined cluster preserved from the previous fix run's
overlap-guard block (both clusters edit main.ts).

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

* fix(voice): 2 defect(s) (OC-0010, OC-0011)

* fix(ws): 1 defect(s) (OC-0050)

* fix(db): 1 defect(s) (OC-0052)

* fix(client): 1 defect(s) (OC-0054)

* fix(client): 1 defect(s) (OC-0059)

* fix(auth): 1 defect(s) (OC-0061)

* fix(ws): 1 defect(s) (OC-0062)

* fix(client): 1 defect(s) (OC-0064)

* fix(service): 1 defect(s) (OC-0070)

* fix(ws): 1 defect(s) (OC-0073)

* fix(service): 2 defect(s) (OC-0075, OC-0120)

* fix(admin): 1 defect(s) (OC-0076)

* fix(voice): 1 defect(s) (OC-0084)

* fix(client): 2 defect(s) (OC-0085, OC-0094)

Scope collapsed-category persistence to the connected host instead of the
server display name, and stop the DM back button from jumping to the first
text channel when DM mode was entered without recording channelBeforeDm.

* fix(service): 1 defect(s) (OC-0087)

* fix(client): 1 defect(s) (OC-0089)

* fix(ws): 1 defect(s) (OC-0091)

* fix(api): 1 defect(s) (OC-0093)

* fix(identity): 1 defect(s) (OC-0118)

* fix(dm): 1 defect(s) (OC-0119)

* fix(voice): 1 defect(s) (OC-0135)

* fix(api): 1 defect(s) (OC-0137)

* fix(client): 1 defect(s) (OC-0142)

* fix(client): 1 defect(s) (OC-0144)

* fix(admin): 1 defect(s) (OC-0145)

* fix(updater): 1 defect(s) (OC-0146)

* fix(client): 1 defect(s) (OC-0150)

* fix(mentions): 1 defect(s) (OC-0131)

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 18:48:10 +02:00

218 lines
6.1 KiB
Go

package ws_test
// handlers_command_test.go — tests for the chat_command handler and
// plugin EventSink wiring (Phase C Step 9).
import (
"bytes"
"encoding/json"
"testing"
"github.com/owncord/server/plugin"
"github.com/owncord/server/ws"
)
// compile-time check that SetPluginRegistry is exported.
var _ = (*ws.Hub)(nil)
// ─── chat_command dispatch via HandleMessageForTest ───────────────────────────
// TestChatCommand_NoRegistry returns an error when no plugin registry is wired.
func TestChatCommand_NoRegistry_ReturnsError(t *testing.T) {
hub, database := newTestHub(t)
_ = database
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
raw, _ := json.Marshal(map[string]any{
"type": "chat_command",
"payload": map[string]any{
"channel_id": int64(1),
"command": "/hello",
"args": []string{},
},
})
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"] != "error" {
t.Fatalf("expected type=error, got %v; raw=%s", env["type"], msg)
}
default:
t.Fatal("expected error message to client")
}
}
// TestChatCommand_UnknownCommand returns an error when the registry has no
// plugin owning the command.
func TestChatCommand_UnknownCommand_ReturnsError(t *testing.T) {
hub, database := newTestHub(t)
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub.SetPluginRegistry(reg)
raw, _ := json.Marshal(map[string]any{
"type": "chat_command",
"payload": map[string]any{
"channel_id": int64(1),
"command": "/notexist",
"args": []string{},
},
})
hub.HandleMessageForTest(c, raw)
select {
case msg := <-send:
var env map[string]any
_ = json.Unmarshal(msg, &env)
if env["type"] != "error" {
t.Fatalf("expected type=error, got %v", env["type"])
}
default:
t.Fatal("expected error message to client")
}
}
// TestChatCommand_MalformedPayload returns bad-request when payload is not valid JSON.
func TestChatCommand_MalformedPayload_ReturnsBadRequest(t *testing.T) {
hub, database := newTestHub(t)
_ = database
send := make(chan []byte, 4)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
raw := []byte(`{"type":"chat_command","payload":"not-an-object"}`)
hub.HandleMessageForTest(c, raw)
select {
case msg := <-send:
var env map[string]any
_ = json.Unmarshal(msg, &env)
if env["type"] != "error" {
t.Fatalf("expected type=error, got %v", env["type"])
}
default:
t.Fatal("expected error message")
}
}
// TestChatCommand_RateLimited_ReturnsError verifies that chat_command is
// throttled per-user, same as every other V2 handler (OC-0091): a burst of
// commands beyond the limit must be rejected with RATE_LIMITED instead of
// running DispatchCommand (and therefore the plugin's WASM invocation) once
// per frame with no cap.
func TestChatCommand_RateLimited_ReturnsError(t *testing.T) {
hub, database := newTestHub(t)
send := make(chan []byte, 32)
c := ws.NewTestClient(hub, 1, send)
hub.Register(c)
defer hub.Unregister(c)
reg, err := plugin.NewRegistry(plugin.Config{Store: database})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
hub.SetPluginRegistry(reg)
sawRateLimited := false
for i := range 20 {
raw, _ := json.Marshal(map[string]any{
"type": "chat_command",
"payload": map[string]any{
"channel_id": int64(1),
"command": "/notexist",
"args": []string{},
},
})
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)
}
payload, _ := env["payload"].(map[string]any)
if payload != nil && payload["code"] == "RATE_LIMITED" {
sawRateLimited = true
}
default:
t.Fatalf("expected a response for message %d", i)
}
}
if !sawRateLimited {
t.Fatal("expected at least one RATE_LIMITED response within 20 rapid chat_command frames")
}
}
// ─── EventSink.Emit ───────────────────────────────────────────────────────────
// TestEventSink_Emit_DeliversToBroadcaster verifies that Emit calls the wired
// broadcaster with the correct channelID and payload.
func TestEventSink_Emit_DeliversToBroadcaster(t *testing.T) {
sink := plugin.NewEventSink()
var gotChannelID int64
var gotPayload []byte
sink.SetBroadcaster(func(channelID int64, payload []byte) {
gotChannelID = channelID
gotPayload = payload
})
want := []byte(`{"type":"plugin_event"}`)
sink.Emit(42, want)
if gotChannelID != 42 {
t.Fatalf("expected channelID=42, got %d", gotChannelID)
}
if !bytes.Equal(gotPayload, want) {
t.Fatalf("expected payload=%s, got %s", want, gotPayload)
}
}
// TestEventSink_Emit_NilBroadcaster_NoOp verifies that Emit is safe when no
// broadcaster has been set.
func TestEventSink_Emit_NilBroadcaster_NoOp(t *testing.T) {
sink := plugin.NewEventSink()
sink.Emit(1, []byte(`{"type":"x"}`)) // must not panic
}
// TestEventSink_Emit_NilSink_NoOp verifies Emit is nil-safe.
func TestEventSink_Emit_NilSink_NoOp(t *testing.T) {
var sink *plugin.EventSink
sink.Emit(1, []byte(`{}`)) // must not panic
}
// ─── Hub plugin-sink wiring ───────────────────────────────────────────────────
// TestHub_SetPluginEventSink_NoOp verifies that wiring a plugin sink and
// broadcasting through the hub does not panic (default build no-ops Dispatch).
func TestHub_SetPluginEventSink_NoOp(t *testing.T) {
hub, database := newTestHub(t)
_ = database
go hub.Run()
defer hub.Stop()
sink := plugin.NewEventSink()
hub.SetPluginEventSink(sink)
// Must not panic.
hub.BroadcastToAll([]byte(`{"type":"test"}`))
}