Files
OwnCord/Server/ws/registry_test.go
T
J3vbandClaude Fable 5 5c91d2492b feat(ws): finish the V2 dispatch migration and delete V1
Port the last three V1 message types to typed V2 handlers, then remove
the V1 registry and fallback path so handleMessage has a single dispatch
generation (audit A-2026-07-09 / backlog item 11). Server-internal only —
the envelope wire format is unchanged, no client/protocol edits.

- chat_command: ChatCommandCmd + constructor (empty/args guards) and a
  V2 handler returning an ephemeral Reply plus a channel-routed
  PluginBroadcastEvent gated by MessageService.CanPost; PluginDeps reads
  the registry live (wired post-construction)
- voice_join/voice_leave: V2 handlers gate parse/rate-limit and hand off
  to the still-hub-internal handleVoiceJoin/handleVoiceLeave routines via
  new Result.JoinVoice / Result.LeaveVoice appliers (those routines are
  also called un-throttled on disconnect and channel switch)
- delete HandlerRegistry.handlers/Register/Dispatch/RegisteredTypes/
  IsRegisteredV1/hasV2, the MessageHandler type, and the V1-shadowing
  guard; NewHub registers only V2
- tests: per-handler V2 tests + a parity guard asserting every command
  constructor has a V2 handler and vice versa (locks the migration shut)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 11:41:58 +02:00

220 lines
7.1 KiB
Go

package ws
import (
"context"
"fmt"
"sort"
"strings"
"testing"
)
// fullV2Registry registers the same handler set NewHub wires, so the
// migration-completeness tests below exercise the real production surface.
func fullV2Registry() *HandlerRegistry {
r := NewHandlerRegistry()
registerPingHandler(r, PingDeps{})
registerChatHandlers(r, ChatDeps{})
registerPresenceHandlers(r, PresenceDeps{})
registerReactionHandlers(r, ReactionDeps{})
r.RegisterV2(MsgTypeChatCommand, handleChatCommandV2, PluginDeps{})
registerVoiceControlsV2(r, VoiceDeps{})
return r
}
func TestRegisterV2AndDispatchV2(t *testing.T) {
r := NewHandlerRegistry()
handler := func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
_ = deps.(PingDeps) // verify deps wiring
return Result{Reply: []byte("pong")}
}
r.RegisterV2("ping", handler, PingDeps{})
cmd := &PingCmd{}
info := ClientInfo{UserID: 1, Username: "test", ReqID: "r1"}
result, ok := r.DispatchV2(context.Background(), cmd, info)
if !ok {
t.Fatal("DispatchV2 returned false for registered type")
}
if string(result.Reply) != "pong" {
t.Errorf("expected reply %q, got %q", "pong", string(result.Reply))
}
}
func TestDispatchV2_UnknownType_ReturnsFalse(t *testing.T) {
r := NewHandlerRegistry()
cmd := &PingCmd{}
_, ok := r.DispatchV2(context.Background(), cmd, ClientInfo{})
if ok {
t.Fatal("DispatchV2 returned true for unregistered type")
}
}
func TestDispatchV2_PanicIsRecovered(t *testing.T) {
r := NewHandlerRegistry()
r.RegisterV2("ping", func(_ context.Context, _ Command, _ ClientInfo, _ any) Result {
panic("simulated internal panic")
}, PingDeps{})
result, ok := r.DispatchV2(context.Background(), &PingCmd{userID: 1}, ClientInfo{})
if !ok {
t.Fatal("DispatchV2 must return ok=true even after a panic (handler was found)")
}
ce, isCE := result.Error.(ClientError)
if !isCE {
t.Fatalf("expected ClientError after panic recovery, got %T", result.Error)
}
if ce.Code != ErrCodeInternal {
t.Errorf("expected ErrCodeInternal, got %q", ce.Code)
}
}
func TestRegisterV2_DuplicateGuard_Panics(t *testing.T) {
r := NewHandlerRegistry()
handler := func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
return Result{}
}
r.RegisterV2("ping", handler, PingDeps{})
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic from duplicate guard, got none")
}
}()
r.RegisterV2("ping", handler, PingDeps{})
}
func TestRegisteredV2Types(t *testing.T) {
r := NewHandlerRegistry()
handler := func(ctx context.Context, cmd Command, info ClientInfo, deps any) Result {
return Result{}
}
r.RegisterV2("ping", handler, PingDeps{})
r.RegisterV2("typing_start", handler, PresenceDeps{})
types := r.RegisteredV2Types()
sort.Strings(types)
if len(types) != 2 || types[0] != "ping" || types[1] != "typing_start" {
t.Errorf("expected [ping typing_start], got %v", types)
}
}
// TestHandlerRegistry_AllExpectedTypesRegistered pins the full set of
// dispatchable types. After the V1→V2 migration completed, every type — voice
// join/leave and plugin commands included — is a typed V2 handler.
func TestHandlerRegistry_AllExpectedTypesRegistered(t *testing.T) {
r := fullV2Registry()
expected := []string{
"ping",
"typing_start",
"presence_update",
"channel_focus",
"reaction_add",
"reaction_remove",
"chat_send",
"chat_edit",
"chat_delete",
"chat_command",
"voice_join",
"voice_leave",
"voice_mute",
"voice_deafen",
"voice_camera",
"voice_screenshare",
"voice_e2ee_announce",
"voice_e2ee_offer",
"voice_token_refresh",
}
registered := r.RegisteredV2Types()
sort.Strings(registered)
sort.Strings(expected)
if len(registered) != len(expected) {
t.Fatalf("expected %d registered types, got %d\nexpected: %v\ngot: %v",
len(expected), len(registered), expected, registered)
}
for i, typ := range expected {
if registered[i] != typ {
t.Errorf("mismatch at index %d: expected %q, got %q", i, typ, registered[i])
}
}
}
// TestMigrationComplete_ConstructorHandlerParity locks the migration shut:
// every command constructor must have a registered V2 handler and vice versa,
// so a V1-style handler (a constructor with no V2 handler, or a handler with no
// strict parser) can never creep back in.
func TestMigrationComplete_ConstructorHandlerParity(t *testing.T) {
r := fullV2Registry()
v2 := make(map[string]bool)
for _, typ := range r.RegisteredV2Types() {
v2[typ] = true
if _, ok := getCommandConstructor(typ); !ok {
t.Errorf("V2 handler %q has no command constructor (strict parser missing)", typ)
}
}
for typ := range commandConstructors {
if !v2[typ] {
t.Errorf("command constructor %q has no registered V2 handler", typ)
}
}
}
// TestAllV2Types_SmokeDispatch verifies that dispatching a minimal command
// for every V2-registered type does not panic (validates deps wiring).
func TestAllV2Types_SmokeDispatch(t *testing.T) {
r := fullV2Registry()
// Minimal command for each V2 type — just needs Type() and UserID().
cmds := map[string]Command{
MsgTypePing: PingCmd{userID: 1},
MsgTypeChatSend: ChatSendCmd{userID: 1, channelID: 1},
MsgTypeChatEdit: ChatEditCmd{userID: 1, messageID: 1},
MsgTypeChatDelete: ChatDeleteCmd{userID: 1, messageID: 1},
MsgTypeChatCommand: ChatCommandCmd{userID: 1, channelID: 1, command: "/x"},
MsgTypeTypingStart: TypingStartCmd{userID: 1, channelID: 1},
MsgTypePresenceUpdate: PresenceUpdateCmd{userID: 1, status: "online"},
MsgTypeChannelFocus: ChannelFocusCmd{userID: 1, channelID: 1},
MsgTypeReactionAdd: ReactionAddCmd{userID: 1, messageID: 1, emoji: "👍"},
MsgTypeReactionRemove: ReactionRemoveCmd{userID: 1, messageID: 1, emoji: "👍"},
MsgTypeVoiceJoin: VoiceJoinCmd{userID: 1, channelID: 1},
MsgTypeVoiceLeave: VoiceLeaveCmd{userID: 1},
MsgTypeVoiceMute: VoiceMuteCmd{userID: 1},
MsgTypeVoiceDeafen: VoiceDeafenCmd{userID: 1},
MsgTypeVoiceCamera: VoiceCameraCmd{userID: 1},
MsgTypeVoiceScreenshare: VoiceScreenshareCmd{userID: 1},
MsgTypeVoiceE2EEAnnounce: VoiceE2EEAnnounceCmd{userID: 1},
MsgTypeVoiceE2EEOffer: VoiceE2EEOfferCmd{userID: 1},
MsgTypeVoiceTokenRefresh: VoiceTokenRefreshCmd{userID: 1},
}
for _, typ := range r.RegisteredV2Types() {
cmd, exists := cmds[typ]
if !exists {
t.Errorf("no smoke command defined for V2 type %q", typ)
continue
}
// We only care that the deps type assertion succeeds (no "interface
// conversion" panic). Nil-pointer panics from zero-value DB/Limiter
// fields are expected and harmless for this smoke test.
func() {
defer func() {
if rec := recover(); rec != nil {
msg := fmt.Sprintf("%v", rec)
if strings.Contains(msg, "interface conversion") {
t.Errorf("V2 type %q: deps type assertion failed: %v", typ, rec)
}
// nil-pointer panics are expected with zero-value deps
}
}()
// Bypass DispatchV2's own recover so we can inspect the panic value.
entry := r.handlersV2[typ]
entry.handler(context.Background(), cmd, ClientInfo{UserID: 1}, entry.deps)
}()
}
}