package transportunibus_test import ( "context" "net" "net/http" "net/http/httptest" "path/filepath" "sync" "testing" "time" "github.com/enmanuel/agents/internal/config" "github.com/enmanuel/agents/pkg/transport" "github.com/enmanuel/agents/shell/transportunibus" cs "fn-registry/functions/cybersecurity" "github.com/enmanuel/unibus/pkg/blobstore" "github.com/enmanuel/unibus/pkg/client" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/membership" "github.com/enmanuel/unibus/pkg/room" server "github.com/nats-io/nats-server/v2/server" ) // harness boots an embedded NATS + an in-process membershipd, mirroring the // unibus test harness so this adapter can be exercised without any external // service. type harness struct { natsURL string ctrlURL string ns *server.Server httpts *httptest.Server } func freePort(t *testing.T) int { t.Helper() l, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("free port: %v", err) } defer l.Close() return l.Addr().(*net.TCPAddr).Port } func newHarness(t *testing.T) *harness { t.Helper() dir := t.TempDir() ns, err := embeddednats.StartHost(filepath.Join(dir, "js"), "127.0.0.1", freePort(t)) if err != nil { t.Fatalf("embedded nats: %v", err) } store, err := membership.Open(filepath.Join(dir, "unibus.db")) if err != nil { ns.Shutdown() t.Fatalf("membership store: %v", err) } blobs, err := blobstore.New(filepath.Join(dir, "blobs")) if err != nil { ns.Shutdown() t.Fatalf("blob store: %v", err) } httpts := httptest.NewServer(membership.NewServer(store, blobs)) h := &harness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts} t.Cleanup(func() { httpts.Close() store.Close() ns.Shutdown() ns.WaitForShutdown() }) return h } func waitHealth(t *testing.T, ctrlURL string) { t.Helper() deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { resp, err := http.Get(ctrlURL + "/healthz") if err == nil && resp.StatusCode == 200 { resp.Body.Close() return } if resp != nil { resp.Body.Close() } time.Sleep(50 * time.Millisecond) } t.Fatalf("membershipd never became healthy") } // botCfg builds a BusCfg pointing the bot at the harness, with a fresh identity // file under the test's temp dir. func botCfg(t *testing.T, h *harness, handle string) config.BusCfg { t.Helper() return config.BusCfg{ NatsURL: h.natsURL, CtrlURL: h.ctrlURL, IdentityPath: filepath.Join(t.TempDir(), handle+".id"), Handle: handle, } } // TestBotEchoesInEncryptedRoom is the headline room-based test: a human peer // creates an encrypted (room.ModeMatrix) room, invites the bot by its endpoint, // and publishes a mention. The bot — driven by Transport.Run + a tiny echo // handler that replies via Reply — answers IN THE SAME room, and the human // receives the reply decrypted. No Matrix is involved end to end. func TestBotEchoesInEncryptedRoom(t *testing.T) { h := newHarness(t) waitHealth(t, h.ctrlURL) bot, err := transportunibus.New(botCfg(t, h, "demo"), nil) if err != nil { t.Fatalf("bot transport: %v", err) } defer bot.Close() ctx, cancel := context.WithCancel(context.Background()) defer cancel() go func() { _ = bot.Run(ctx, transportunibus.DemoEchoHandler(bot, nil)) }() // Human peer. userID, err := cs.GenerateIdentity() if err != nil { t.Fatalf("user identity: %v", err) } user, err := client.New(h.natsURL, h.ctrlURL, userID) if err != nil { t.Fatalf("user client: %v", err) } defer user.Close() // Human creates an encrypted room and invites the bot by its endpoint id. roomID, err := user.CreateRoom("conv.demo", room.ModeMatrix) if err != nil { t.Fatalf("create room: %v", err) } // Invite the bot by its full endpoint (id + public keys), so the human can // seal the encrypted room key for it. if err := user.Invite(roomID, bot.BusEndpoint()); err != nil { t.Fatalf("invite bot: %v", err) } // Human subscribes to the same room to receive the bot's reply. var mu sync.Mutex var bodies []string var sawAnchored bool if err := user.Join(roomID); err != nil { t.Fatalf("user join: %v", err) } sub, err := user.Subscribe(roomID, func(f frame.Frame, plaintext []byte) { mu.Lock() bodies = append(bodies, string(plaintext)) if f.ReplyTo != "" { sawAnchored = true } mu.Unlock() }) if err != nil { t.Fatalf("user subscribe: %v", err) } defer sub.Unsubscribe() // Give the bot's discovery ticker time to find, join and subscribe to the room. time.Sleep(300 * time.Millisecond) // Human posts a message mentioning the bot's handle. if err := user.Publish(roomID, []byte("hola demo")); err != nil { t.Fatalf("user publish: %v", err) } if _, ok := waitBody(&mu, &bodies, "echo: hola demo", 5*time.Second); !ok { t.Fatalf("never received echo reply; got %v", snapshot(&mu, &bodies)) } mu.Lock() anchored := sawAnchored mu.Unlock() if !anchored { t.Fatalf("reply did not carry a ReplyTo anchor") } // Command over the bus → pong, in the same room. if err := user.Publish(roomID, []byte("!ping")); err != nil { t.Fatalf("user publish ping: %v", err) } if _, ok := waitBody(&mu, &bodies, "pong", 5*time.Second); !ok { t.Fatalf("never received pong; got %v", snapshot(&mu, &bodies)) } } // TestRunStopsOnContextCancel is an error/lifecycle path: Run must return when // its context is cancelled rather than blocking forever. func TestRunStopsOnContextCancel(t *testing.T) { h := newHarness(t) waitHealth(t, h.ctrlURL) bot, err := transportunibus.New(botCfg(t, h, "lifecycle"), nil) if err != nil { t.Fatalf("bot transport: %v", err) } defer bot.Close() ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- bot.Run(ctx, func(context.Context, transport.InboundMessage) {}) }() time.Sleep(100 * time.Millisecond) cancel() select { case err := <-done: if err != context.Canceled { t.Fatalf("Run returned %v, want context.Canceled", err) } case <-time.After(3 * time.Second): t.Fatalf("Run did not return after context cancel") } } // ---- helpers ---- func waitBody(mu *sync.Mutex, slice *[]string, want string, timeout time.Duration) (string, bool) { deadline := time.Now().Add(timeout) for time.Now().Before(deadline) { mu.Lock() for _, s := range *slice { if s == want { mu.Unlock() return s, true } } mu.Unlock() time.Sleep(25 * time.Millisecond) } return "", false } func snapshot(mu *sync.Mutex, slice *[]string) []string { mu.Lock() defer mu.Unlock() return append([]string(nil), (*slice)...) }