package gateway import ( "context" "testing" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" ) // TestHub_SendProactive_OwnerAndAdmin verifies a proactive push reaches exactly // the owner/admin connections and returns the delivered count; a viewer or // unauthenticated (nil-claims) connection never receives it. func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) owner := newFakeConn(hb, 8) owner.claims = &auth.Claims{Role: auth.RoleOwner} admin := newFakeConn(hb, 8) admin.claims = &auth.Claims{Role: auth.RoleAdmin} viewer := newFakeConn(hb, 8) viewer.claims = &auth.Claims{Role: auth.RoleViewer} bare := newFakeConn(hb, 8) // claims nil: unauthenticated for _, c := range []*wsConn{owner, admin, viewer, bare} { hb.register(c) } msg := adapter.UnifiedMessage{ Platform: "cyrene-backthink", ChannelType: adapter.ChannelDirect, Content: "记得喝水哦", ContentType: adapter.ContentText, Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"}, ChannelID: "kook:123", SenderID: "uid-999", MessageID: "m-42", } n, err := hb.SendProactive(context.Background(), msg) if err != nil { t.Fatalf("SendProactive err = %v", err) } if n != 2 { t.Fatalf("count = %d, want 2 (owner+admin; viewer and bare excluded)", n) } for _, c := range []*wsConn{owner, admin} { ev := readFakeFrame(t, c) if ev.Type != "proactive_message" { t.Fatalf("frame type = %q, want proactive_message (frame=%+v)", ev.Type, ev) } data, ok := ev.Data.(map[string]any) if !ok { t.Fatalf("frame data type = %T, want map[string]any", ev.Data) } if data["content"] != "记得喝水哦" { t.Fatalf("frame content = %q, want the proactive text", data["content"]) } if data["sender"] != "Cyrene" { t.Fatalf("frame sender = %q, want Cyrene", data["sender"]) } if data["platform"] != "cyrene-backthink" { t.Fatalf("frame platform = %q, want cyrene-backthink", data["platform"]) } } for _, c := range []*wsConn{viewer, bare} { if err := connHasNoFrame(c); err != nil { t.Fatalf("unexpected frame for a non-owner/admin conn: %v", err) } } } // TestHub_SendProactive_NoOnlineOwner verifies a zero count (no error) when no // owner/admin connection is live, so the caller falls back to the IM bridge. func TestHub_SendProactive_NoOnlineOwner(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) viewer := newFakeConn(hb, 8) viewer.claims = &auth.Claims{Role: auth.RoleViewer} hb.register(viewer) n, err := hb.SendProactive(context.Background(), adapter.UnifiedMessage{Content: "hi"}) if err != nil { t.Fatalf("SendProactive err = %v", err) } if n != 0 { t.Fatalf("count = %d, want 0", n) } } // TestHub_SendProactive_CancelledContext verifies a cancelled context denies the // push (fail closed) with a wrapped error. func TestHub_SendProactive_CancelledContext(t *testing.T) { t.Parallel() hb := newTestHub(nil, nil) ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := hb.SendProactive(ctx, adapter.UnifiedMessage{Content: "hi"}); err == nil { t.Fatalf("expected a context error, got nil") } } // TestServer_SendProactive_DelegatesToHub verifies Server.SendProactive mirrors the // hub so the composition root can hand the gateway itself to the sink setter. func TestServer_SendProactive_DelegatesToHub(t *testing.T) { t.Parallel() env := newEnv(t, nil, false) c := newFakeConn(env.srv.hub, 8) c.claims = &auth.Claims{Role: auth.RoleOwner} env.srv.hub.register(c) n, err := env.srv.SendProactive(context.Background(), adapter.UnifiedMessage{Content: "hi"}) if err != nil { t.Fatalf("SendProactive err = %v", err) } if n != 1 { t.Fatalf("count = %d, want 1", n) } ev := readFakeFrame(t, c) if ev.Type != "proactive_message" { t.Fatalf("frame type = %q, want proactive_message", ev.Type) } }