package registry import ( "context" "errors" "strings" "testing" "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" ) // 本文件钉住渠道热重载的窄方法 `ReplaceChannel`(upsert,design/04 §3.1): // 热重载重建出来的适配器**必然同名**,Register 会 ErrDuplicate 把新实例挡在门外, // 注册表于是继续指向旧实例——管理面板显示「未连接」、点「连接」驱动的是旧实例。 // stubChannel 是名字可指定、连接状态可变的最小渠道(只用于注册/替换的判定)。 type stubChannel struct { name string up bool } func (s *stubChannel) Name() string { return s.name } func (s *stubChannel) Connect(context.Context) error { s.up = true; return nil } func (s *stubChannel) Disconnect(context.Context) error { s.up = false; return nil } func (s *stubChannel) IsConnected() bool { return s.up } func (s *stubChannel) HealthCheck(context.Context) error { return nil } var _ sdk.Channel = (*stubChannel)(nil) // TestReplaceChannelSwapsValueAndKeepsPolicy 是热重载的主判据: // - 同名条目换成了**新实例**(调用方随后 Connect/IsConnected 驱动的就是新的那一把); // - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于适配器实现; // - Kind/Description 与 RegisterChannel 同口径。 func TestReplaceChannelSwapsValueAndKeepsPolicy(t *testing.T) { const name = "qq-obv11:9616b27ae314253c54419b7541538cdc" r := New() old := &stubChannel{name: name, up: true} if err := r.RegisterChannel(old); err != nil { t.Fatalf("RegisterChannel: %v", err) } if err := r.SetAllowInGroup(name, true); err != nil { t.Fatalf("SetAllowInGroup: %v", err) } next := &stubChannel{name: name, up: true} // 先钉住「为什么必须是替换而不是 Register」:同名注册必被拒。 if err := r.RegisterChannel(next); !errors.Is(err, ErrDuplicate) { t.Fatalf("同名的 RegisterChannel err = %v, want ErrDuplicate(热重载必然同名)", err) } if err := r.ReplaceChannel(next); err != nil { t.Fatalf("ReplaceChannel: %v", err) } cap, err := r.Get(name) if err != nil { t.Fatalf("Get: %v", err) } // 判据是接口值同一性:新旧实例的 Name() 完全相同,比名字必然假绿。 if cap.Value != sdk.Channel(next) { t.Fatalf("替换后 Value = %#v, want 新实例", cap.Value) } if cap.Value == sdk.Channel(old) { t.Fatal("替换后仍指向旧实例:面板会显示未连接、点「连接」驱动旧实例") } if !cap.Policy.AllowInGroup { t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于适配器构造产出,必须沿用") } if cap.Kind != KindChannel { t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindChannel) } if cap.Description != name { t.Fatalf("替换后 Description = %q, want %q(与 RegisterChannel 同口径)", cap.Description, name) } } // TestReplaceChannelRegistersMissingName:名字不存在时按 RegisterChannel 同口径新增 // —— 新加进配置的账号,热重载是它第一次出现在注册表里的时机,没有别处替它注册。 func TestReplaceChannelRegistersMissingName(t *testing.T) { const name = "kook:conf0" r := New() ch := &stubChannel{name: name, up: true} if err := r.ReplaceChannel(ch); err != nil { t.Fatalf("ReplaceChannel: %v", err) } cap, err := r.Get(name) if err != nil { t.Fatalf("Get: %v", err) } if cap.Value != sdk.Channel(ch) { t.Fatalf("新增后 Value = %#v, want 该适配器", cap.Value) } if cap.Kind != KindChannel || cap.Description != name { t.Fatalf("新增条目形状 = %q/%q, want %q/%q", cap.Kind, cap.Description, KindChannel, name) } // 面板按 Kind 枚举渠道卡片,新条目必须能列出来。 listed := r.List(KindChannel) if len(listed) != 1 || listed[0].Name != name { t.Fatalf("List(KindChannel) = %#v, want 仅 %q", listed, name) } } // TestReplaceChannelUpsertHonorsPersistedPolicy 钉住新增分支与 Register 的同口径性: // DB 是策略的权威载体(裁决 10.4.2),已落盘的策略要覆盖新条目的零值。 func TestReplaceChannelUpsertHonorsPersistedPolicy(t *testing.T) { r := New() r.persisted = map[string]ToolPolicy{"kook": {AllowInGroup: true}} if err := r.ReplaceChannel(&stubChannel{name: "kook"}); err != nil { t.Fatalf("ReplaceChannel: %v", err) } cap, err := r.Get("kook") if err != nil { t.Fatalf("Get: %v", err) } if !cap.Policy.AllowInGroup { t.Fatal("新增分支丢掉了已落盘的策略:与 Register 的口径不一致") } } // TestReplaceChannelRefusesNonChannelEntry:同名条目不是渠道时拒绝,绝不悄悄改掉它的 Kind。 func TestReplaceChannelRefusesNonChannelEntry(t *testing.T) { r := New() if err := r.Register(Capability{Name: "kook", Kind: KindMCP, Value: struct{}{}}); err != nil { t.Fatalf("Register: %v", err) } err := r.ReplaceChannel(&stubChannel{name: "kook"}) if !errors.Is(err, ErrInvalidArgument) { t.Fatalf("err = %v, want ErrInvalidArgument", err) } if err == nil || !strings.Contains(err.Error(), string(KindMCP)) { t.Fatalf("错误消息 %q 里应指出既有类型 %q", err, KindMCP) } cap, gerr := r.Get("kook") if gerr != nil { t.Fatalf("Get: %v", gerr) } if cap.Kind != KindMCP { t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind) } if _, ok := cap.Value.(*stubChannel); ok { t.Fatal("被拒的替换却改了 Value") } } // TestReplaceChannelRejectsNilAndEmptyName:两个不该被静默吞掉的调用错误。 func TestReplaceChannelRejectsNilAndEmptyName(t *testing.T) { r := New() if err := r.ReplaceChannel(nil); !errors.Is(err, ErrInvalidArgument) { t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err) } if err := r.ReplaceChannel(&stubChannel{}); !errors.Is(err, ErrInvalidArgument) { t.Fatalf("替换成空名 err = %v, want ErrInvalidArgument", err) } if got := r.List(""); len(got) != 0 { t.Fatalf("非法入参不得写进注册表,实际写入 %#v", got) } }