Files
Cyrene_v2/cmd/cyrene/channels_test.go
T
Aska 0ed7984de3
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat: 渠道管理分两块(.env必要回退+删除同步+可用类型/已保存分开)
agent-120 .env只留必要回退:
- .env.example KOOK移4可选业务字段(RECEIVE_MODE/WEBHOOK_PATH/VERIFY_TOKEN/ENCRYPT_KEY),只留token/base_url; channelAccountsFromEnv不读这4个; 说明业务字段走channelConfigForm.channels.json
- 黑名单/限流/receive_mode等走管理面板热更新, env仅必要连接回退

agent-121 删除渠道列表同步(根因: adminListChannels枚举caps,删除不同步caps):
- adminListChannels 以channels.json为准: 遍历caps时已删账号adapter跳过(s.channels注入时), 删除/保存后列表=channels.json, 删除即消失
- (capReg Remove后续可加,范围外标注)

agent-122 渠道管理分两块显示(主人:不割裂):
- ①可用适配器类型(顶部PLATFORM_TYPES全集grid卡片,已配置/未配置Tag,点新建配置)
- ②已保存配置(下方AdminTable过滤accounts>0,账号+bot身份+掩码token提示+连接/断开/删除)
- 拆 channelConfigForm.tsx(248行) 表单抽出; Channels.tsx 463→306行; token只回填hint不写值(保存留空不改secret)
全仓 build/vet/test绿+前端build绿(356KB)
2026-09-07 16:16:50 +08:00

218 lines
7.6 KiB
Go

package main
import (
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
)
func testLog() *slog.Logger {
return logger.New(logger.Options{Out: io.Discard})
}
// testFactory builds a channelFactory pointing at a temp (absent) channels.json,
// seeded by the env fallback, so the test reads the env the same way boot does.
func testFactory(t *testing.T) *channelFactory {
t.Helper()
return &channelFactory{
path: filepath.Join(t.TempDir(), "channels.json"),
fallback: channelAccountsFromEnv(),
log: testLog(),
}
}
func TestBuildChannelAdapters_IncludesKook(t *testing.T) {
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
adapters := buildChannelAdapters(testFactory(t))
if !hasAdapter(adapters, "kook") {
t.Fatalf("expected a kook adapter, got %v", names(adapters))
}
}
func TestBuildChannelAdapters_KookMultiAccount(t *testing.T) {
t.Setenv("CHANNELS_JSON", `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret"},"allowUsers":["u-1"],"denyGroups":["g-9"]}]}`)
adapters := buildChannelAdapters(testFactory(t))
if !hasAdapter(adapters, "kook:conf0") {
t.Fatalf("expected a kook:conf0 adapter, got %v", names(adapters))
}
// The named adapter must expose its allow/deny lists via Config().
for _, a := range adapters {
if a.Name() == "kook:conf0" {
cfg := a.Config()
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
}
}
}
}
// TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile verifies a stale
// channels.json with both a bare empty-token kook and a real token account builds
// only the token account, so the bare "kook" never registers (and never wins a
// bare-name open).
func TestBuildChannelAdapters_SkipsEmptyTokenKookFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
raw := `{"kook":[
{"id":"","config":{"base_url":"","bot_token":""}},
{"id":"3fc8","config":{"bot_token":"real-token"}}
]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adapters := buildChannelAdapters(f)
if hasAdapter(adapters, "kook") {
t.Fatalf("bare empty-token kook should not be built, got %v", names(adapters))
}
if !hasAdapter(adapters, "kook:3fc8") {
t.Fatalf("expected kook:3fc8, got %v", names(adapters))
}
}
// TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders verifies the env fallback
// does not synthesize an empty-token kook/telegram/weixin account (only a real
// token produces the account), so a bare placeholder is never registered.
func TestChannelAccountsFromEnv_NoEmptyTokenPlaceholders(t *testing.T) {
for _, k := range []string{"CHANNEL_KOOK_TOKEN", "CHANNEL_TELEGRAM_BOT_TOKEN", "CHANNEL_WEIXIN_ILINK_BOT_TOKEN", "CHANNELS_JSON"} {
t.Setenv(k, "")
}
env := channelAccountsFromEnv()
for _, platform := range []string{"kook", "telegram", "weixin-ilink"} {
if _, ok := env[platform]; ok {
t.Fatalf("empty-token %s should not be synthesized", platform)
}
}
}
// TestChannelAccountsFromEnv_KookPresentWhenTokenSet verifies a set token yields
// the account.
func TestChannelAccountsFromEnv_KookPresentWhenTokenSet(t *testing.T) {
t.Setenv("CHANNEL_KOOK_TOKEN", "kook-secret")
env := channelAccountsFromEnv()
list, ok := env["kook"]
if !ok || len(list) != 1 {
t.Fatalf("expected a synthesized kook account, got %+v", env["kook"])
}
if list[0].Config["bot_token"] != "kook-secret" {
t.Fatalf("bot_token = %v, want kook-secret", list[0].Config["bot_token"])
}
}
func hasAdapter(adapters []adapter.PlatformAdapter, name string) bool {
for _, a := range adapters {
if a.Name() == name {
return true
}
}
return false
}
// TestChannelFactoryBuildFromFile exercises the reload factory against a real
// channels.json file: the factory reads the file (file beats env) and builds a
// named adapter carrying the account's token and black-or-white lists, so a panel
// save → Reload path uses exactly this builder.
func TestChannelFactoryBuildFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
if err := os.WriteFile(path, []byte(`{"kook":[{"id":"conf0","config":{"bot_token":"bt"},"denyGroups":["g-1"],"allowUsers":["u-1"],"groupMsgPerMin":3}]}`), 0o600); err != nil {
t.Fatalf("write channels.json: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adpts, err := f.Build("kook")
if err != nil {
t.Fatalf("Build: %v", err)
}
if !hasAdapter(adpts, "kook:conf0") {
t.Fatalf("kook:conf0 not built, got %v", names(adpts))
}
for _, a := range adpts {
if a.Name() != "kook:conf0" {
continue
}
cfg := a.Config()
if cfg["bot_token"] != "bt" {
t.Errorf("bot_token = %v, want bt", cfg["bot_token"])
}
if got, _ := cfg["denyGroups"].([]string); len(got) != 1 || got[0] != "g-1" {
t.Errorf("denyGroups = %v, want [g-1]", cfg["denyGroups"])
}
if got, _ := cfg["allowUsers"].([]string); len(got) != 1 || got[0] != "u-1" {
t.Errorf("allowUsers = %v, want [u-1]", cfg["allowUsers"])
}
if got, _ := cfg["groupMsgPerMin"].(int); got != 3 {
t.Errorf("groupMsgPerMin = %v, want 3", cfg["groupMsgPerMin"])
}
}
}
// TestNamedAdapterForwardsWebhook verifies the production wrapper forwards a
// kook webhook-mode adapter's InboundHandler/WebhookPath to the channel bridge,
// so bridge.WebhookHandlers() mounts the KOOK callback route on the gateway. The
// webhook business fields (receive_mode/webhook_path) are sourced from
// channels.json — .env only carries the necessary connection fallback.
func TestNamedAdapterForwardsWebhook(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"webhook","webhook_path":"/webhook/kook"}}]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write channels.json: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adpts, err := f.Build("kook")
if err != nil {
t.Fatalf("Build: %v", err)
}
var found *namedAdapter
for _, a := range adpts {
if nwa, ok := a.(*namedAdapter); ok && barePlatform(nwa.Name()) == "kook" {
found = nwa
break
}
}
if found == nil {
t.Fatalf("kook named adapter not found, got %v", names(adpts))
}
if p := found.WebhookPath(); p != "/webhook/kook" {
t.Errorf("WebhookPath = %q, want /webhook/kook", p)
}
if found.InboundHandler() == nil {
t.Error("InboundHandler() = nil, want a mounted handler")
}
}
// TestNamedAdapterNoWebhookInWSMode verifies a ws-mode adapter (all non-webhook
// platforms) does not surface a webhook handler through the wrapper.
func TestNamedAdapterNoWebhookInWSMode(t *testing.T) {
path := filepath.Join(t.TempDir(), "channels.json")
raw := `{"kook":[{"id":"conf0","config":{"bot_token":"kook-secret","receive_mode":"ws"}}]}`
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatalf("write channels.json: %v", err)
}
f := &channelFactory{path: path, fallback: channelAccountsFromEnv(), log: testLog()}
adpts, err := f.Build("kook")
if err != nil {
t.Fatalf("Build: %v", err)
}
for _, a := range adpts {
if barePlatform(a.Name()) != "kook" {
continue
}
if nwa, ok := a.(*namedAdapter); ok {
if p := nwa.WebhookPath(); p != "" {
t.Errorf("ws-mode kook WebhookPath = %q, want empty", p)
}
}
}
}
func names(adapters []adapter.PlatformAdapter) []string {
out := make([]string, 0, len(adapters))
for _, a := range adapters {
out = append(out, a.Name())
}
return out
}