fc644ecd6e
Reemplaza el scaffold del echobot por la plataforma completa de bots traida desde ~/DataProyects/Github/agents_and_robots tras la operacion Matrix-out: los bots ya no hablan por Matrix sino por el bus unibus (modelo todo-rooms + E2E via shell/transportunibus sobre github.com/enmanuel/unibus/pkg/client). - go.mod: replace de unibus -> ../unibus y de fn-registry -> ../../../.. (paths relativos reajustados a la nueva ubicacion dentro de fn_registry). - app.md: bump a 0.2.0, descripcion + arquitectura + comandos + gotchas reales. - modulo Go conservado como github.com/enmanuel/agents (sin reescribir imports). agents_and_robots queda archivado como museo de la era Matrix.
244 lines
6.6 KiB
Go
244 lines
6.6 KiB
Go
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)...)
|
|
}
|