mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
Resolve the PR #8/#9/#10 stack on current dev, including Bitrix24 install callback hardening, migration renumbering, duplicate-domain fail-closed routing, UI textarea/mobile cleanup, and review hardening.
282 lines
8.7 KiB
Go
282 lines
8.7 KiB
Go
package bitrix24
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/bus"
|
|
"github.com/nextlevelbuilder/goclaw/internal/channels"
|
|
)
|
|
|
|
func TestFactory_BareCallReturnsError(t *testing.T) {
|
|
// The bare Factory must refuse to construct — the gateway has to wire
|
|
// FactoryWithPortalStore so the portal store is in scope.
|
|
_, err := Factory("bitrix24", nil, nil, nil, nil)
|
|
if err == nil {
|
|
t.Fatal("bare Factory should error — needs portal store")
|
|
}
|
|
}
|
|
|
|
func TestFactoryWithPortalStore_NilStore(t *testing.T) {
|
|
fn := FactoryWithPortalStore(nil, "")
|
|
_, err := fn("b1", nil, json.RawMessage(`{"portal":"p","bot_code":"c","bot_name":"n"}`), nil, nil)
|
|
if err == nil || !strings.Contains(err.Error(), "nil BitrixPortalStore") {
|
|
t.Fatalf("expected nil-store error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFactoryWithPortalStore_RequiresFields(t *testing.T) {
|
|
fs := newFakeStore()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
defer resetWebhookRouterForTest()
|
|
|
|
cases := []struct {
|
|
name string
|
|
cfg string
|
|
}{
|
|
{"empty", `{}`},
|
|
{"missing bot_code", `{"portal":"p","bot_name":"n"}`},
|
|
{"missing bot_name", `{"portal":"p","bot_code":"c"}`},
|
|
{"missing portal", `{"bot_code":"c","bot_name":"n"}`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
_, err := fn("b1", nil, json.RawMessage(tc.cfg), nil, nil)
|
|
if err == nil {
|
|
t.Fatalf("expected required-field error, got nil")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFactoryWithPortalStore_AppliesDefaults(t *testing.T) {
|
|
fs := newFakeStore()
|
|
resetWebhookRouterForTest()
|
|
defer resetWebhookRouterForTest()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
|
|
ch, err := fn("b1", nil,
|
|
json.RawMessage(`{"portal":"acme","bot_code":"goclaw","bot_name":"GoClaw Bot"}`),
|
|
&bus.MessageBus{}, nil)
|
|
if err != nil {
|
|
t.Fatalf("factory: %v", err)
|
|
}
|
|
bc, ok := ch.(*Channel)
|
|
if !ok {
|
|
t.Fatalf("want *Channel, got %T", ch)
|
|
}
|
|
cfg := bc.Config()
|
|
if cfg.BotType != "B" {
|
|
t.Errorf("BotType default = %q; want \"B\" (Bitrix24 imbot.register default)", cfg.BotType)
|
|
}
|
|
if bc.IsOpenChannelBot() {
|
|
t.Errorf("default bot must not report IsOpenChannelBot()=true")
|
|
}
|
|
if cfg.DMPolicy != string(channels.DMPolicyPairing) {
|
|
t.Errorf("DMPolicy = %q; want pairing", cfg.DMPolicy)
|
|
}
|
|
if cfg.GroupPolicy != string(channels.GroupPolicyOpen) {
|
|
t.Errorf("GroupPolicy = %q; want open", cfg.GroupPolicy)
|
|
}
|
|
if cfg.TextChunkLimit != 4000 {
|
|
t.Errorf("TextChunkLimit = %d; want 4000", cfg.TextChunkLimit)
|
|
}
|
|
if cfg.MediaMaxMB != 20 {
|
|
t.Errorf("MediaMaxMB = %d; want 20", cfg.MediaMaxMB)
|
|
}
|
|
if cfg.ReactionLevel != "minimal" {
|
|
t.Errorf("ReactionLevel = %q; want minimal", cfg.ReactionLevel)
|
|
}
|
|
if cfg.RequireMention == nil || !*cfg.RequireMention {
|
|
t.Errorf("RequireMention should default to true")
|
|
}
|
|
if cfg.Streaming == nil || !*cfg.Streaming {
|
|
t.Errorf("Streaming should default to true")
|
|
}
|
|
if ch.Type() != channels.TypeBitrix24 {
|
|
t.Errorf("Type = %q; want bitrix24", ch.Type())
|
|
}
|
|
if ch.Name() != "b1" {
|
|
t.Errorf("Name = %q; want b1", ch.Name())
|
|
}
|
|
}
|
|
|
|
func TestFactoryWithPortalStore_InvalidJSON(t *testing.T) {
|
|
fs := newFakeStore()
|
|
resetWebhookRouterForTest()
|
|
defer resetWebhookRouterForTest()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
|
|
if _, err := fn("b1", nil, json.RawMessage(`{not json}`), nil, nil); err == nil {
|
|
t.Fatal("expected JSON decode error")
|
|
}
|
|
if _, err := fn("b1", json.RawMessage(`{not json}`),
|
|
json.RawMessage(`{"portal":"p","bot_code":"c","bot_name":"n"}`), nil, nil); err == nil {
|
|
t.Fatal("expected creds decode error")
|
|
}
|
|
}
|
|
|
|
func TestFactoryWithPortalStore_RouterSingleton(t *testing.T) {
|
|
fs := newFakeStore()
|
|
resetWebhookRouterForTest()
|
|
defer resetWebhookRouterForTest()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
|
|
cfg := json.RawMessage(`{"portal":"p1","bot_code":"c1","bot_name":"n1"}`)
|
|
ch1, err := fn("b1", nil, cfg, &bus.MessageBus{}, nil)
|
|
if err != nil {
|
|
t.Fatalf("factory 1: %v", err)
|
|
}
|
|
ch2, err := fn("b2", nil,
|
|
json.RawMessage(`{"portal":"p2","bot_code":"c2","bot_name":"n2"}`),
|
|
&bus.MessageBus{}, nil)
|
|
if err != nil {
|
|
t.Fatalf("factory 2: %v", err)
|
|
}
|
|
r1 := ch1.(*Channel).Router()
|
|
r2 := ch2.(*Channel).Router()
|
|
if r1 != r2 {
|
|
t.Fatal("all channels should share the singleton router")
|
|
}
|
|
}
|
|
|
|
func TestFactoryWithPortalStore_WebhookHandlerFirstClaimWins(t *testing.T) {
|
|
fs := newFakeStore()
|
|
resetWebhookRouterForTest()
|
|
defer resetWebhookRouterForTest()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
|
|
ch1, _ := fn("b1", nil, json.RawMessage(`{"portal":"p1","bot_code":"c1","bot_name":"n1"}`),
|
|
&bus.MessageBus{}, nil)
|
|
ch2, _ := fn("b2", nil, json.RawMessage(`{"portal":"p2","bot_code":"c2","bot_name":"n2"}`),
|
|
&bus.MessageBus{}, nil)
|
|
|
|
// Only the first channel's WebhookHandler returns a path+handler.
|
|
wc1, ok := ch1.(channels.WebhookChannel)
|
|
if !ok {
|
|
t.Fatal("channel does not implement WebhookChannel")
|
|
}
|
|
wc2 := ch2.(channels.WebhookChannel)
|
|
|
|
path1, h1 := wc1.WebhookHandler()
|
|
path2, h2 := wc2.WebhookHandler()
|
|
|
|
if path1 == "" || h1 == nil {
|
|
t.Fatalf("first Channel should return a path+handler, got %q / %v", path1, h1)
|
|
}
|
|
if path2 != "" || h2 != nil {
|
|
t.Errorf("second Channel should return empty, got %q / %v", path2, h2)
|
|
}
|
|
}
|
|
|
|
func TestApplyConfigDefaults_RespectsExplicit(t *testing.T) {
|
|
no := false
|
|
cfg := bitrixInstanceConfig{
|
|
BotType: "O",
|
|
DMPolicy: "open",
|
|
GroupPolicy: "allowlist",
|
|
TextChunkLimit: 1000,
|
|
MediaMaxMB: 5,
|
|
ReactionLevel: "off",
|
|
RequireMention: &no,
|
|
Streaming: &no,
|
|
}
|
|
applyConfigDefaults(&cfg)
|
|
if cfg.BotType != "O" {
|
|
t.Errorf("explicit BotType was overwritten: %q", cfg.BotType)
|
|
}
|
|
if cfg.DMPolicy != "open" || cfg.GroupPolicy != "allowlist" ||
|
|
cfg.TextChunkLimit != 1000 || cfg.MediaMaxMB != 5 || cfg.ReactionLevel != "off" {
|
|
t.Errorf("explicit values were overwritten: %+v", cfg)
|
|
}
|
|
if *cfg.RequireMention || *cfg.Streaming {
|
|
t.Errorf("explicit bool pointers lost: %+v", cfg)
|
|
}
|
|
}
|
|
|
|
// TestFactoryWithPortalStore_BotType covers all three outcomes of the
|
|
// bot_type field: (1) default when omitted, (2) accepted values flow
|
|
// through verbatim, and (3) anything else is rejected at load.
|
|
//
|
|
// Validation happens AFTER applyConfigDefaults, so the "" case exercises
|
|
// the default → "B" path, not the rejection path.
|
|
func TestFactoryWithPortalStore_BotType(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
cfg string
|
|
wantErr bool
|
|
wantTyp string
|
|
wantOC bool // IsOpenChannelBot()
|
|
}{
|
|
{"default_is_B", `{"portal":"p","bot_code":"c","bot_name":"n"}`, false, "B", false},
|
|
{"explicit_B", `{"portal":"p","bot_code":"c","bot_name":"n","bot_type":"B"}`, false, "B", false},
|
|
{"explicit_O_is_open_channel", `{"portal":"p","bot_code":"c","bot_name":"n","bot_type":"O"}`, false, "O", true},
|
|
{"reject_lowercase_b", `{"portal":"p","bot_code":"c","bot_name":"n","bot_type":"b"}`, true, "", false},
|
|
{"reject_unknown_H", `{"portal":"p","bot_code":"c","bot_name":"n","bot_type":"H"}`, true, "", false},
|
|
{"reject_empty_string_only_if_whitespace", `{"portal":"p","bot_code":"c","bot_name":"n","bot_type":" "}`, true, "", false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fs := newFakeStore()
|
|
resetWebhookRouterForTest()
|
|
defer resetWebhookRouterForTest()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
|
|
ch, err := fn("b1", nil, json.RawMessage(tc.cfg), &bus.MessageBus{}, nil)
|
|
if tc.wantErr {
|
|
if err == nil {
|
|
t.Fatalf("expected error for bot_type validation, got nil (cfg=%s)", tc.cfg)
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
bc := ch.(*Channel)
|
|
if got := bc.Config().BotType; got != tc.wantTyp {
|
|
t.Errorf("BotType = %q; want %q", got, tc.wantTyp)
|
|
}
|
|
if got := bc.IsOpenChannelBot(); got != tc.wantOC {
|
|
t.Errorf("IsOpenChannelBot() = %v; want %v", got, tc.wantOC)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestMergeAllowLists(t *testing.T) {
|
|
if got := mergeAllowLists(nil, nil); got != nil {
|
|
t.Errorf("both nil should stay nil; got %v", got)
|
|
}
|
|
got := mergeAllowLists([]string{"a", "b"}, []string{"c"})
|
|
want := []string{"a", "b", "c"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("len = %d; want %d", len(got), len(want))
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("[%d] = %q; want %q", i, got[i], want[i])
|
|
}
|
|
}
|
|
}
|
|
|
|
// sanityContext ensures the Start early-return path on missing tenant id
|
|
// doesn't try to hit the store. Covers a common mis-wiring where the
|
|
// InstanceLoader forgets SetTenantID before Start.
|
|
func TestChannel_Start_MissingTenantID_FailsFast(t *testing.T) {
|
|
fs := newFakeStore()
|
|
resetWebhookRouterForTest()
|
|
defer resetWebhookRouterForTest()
|
|
fn := FactoryWithPortalStore(fs, "")
|
|
ch, err := fn("b1", nil, json.RawMessage(`{"portal":"p","bot_code":"c","bot_name":"n"}`),
|
|
&bus.MessageBus{}, nil)
|
|
if err != nil {
|
|
t.Fatalf("factory: %v", err)
|
|
}
|
|
|
|
if err := ch.Start(context.Background()); err == nil {
|
|
t.Fatal("Start must fail when TenantID is zero")
|
|
}
|
|
}
|