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,"directMsgPerMin":-1}]}`), 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"]) } // directMsgPerMin 同一条链:channels.json 的账号顶层字段 → namedAdapter.Config() → // 桥的 gateOptions。断桥这一环就等于「面板/文件配了却没人读」(负值必须原样透出)。 if got, _ := cfg["directMsgPerMin"].(int); got != -1 { t.Errorf("directMsgPerMin = %v, want -1", cfg["directMsgPerMin"]) } } } // 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 }