mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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>
220 lines
7.1 KiB
Go
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)
|
|
}()
|
|
}
|
|
}
|