Files
2026-06-02 23:14:41 +08:00

921 lines
25 KiB
Go

package core
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
// helpers ------------------------------------------------------------------
func startTestBridge(t *testing.T, token string) (*BridgeServer, string) {
t.Helper()
var bs *BridgeServer
if token == "" {
// Use insecure mode for tests without token
bs = NewBridgeServerInsecure(0, token, "/bridge/ws", nil)
} else {
bs = NewBridgeServer(0, token, "/bridge/ws", nil)
}
if bs == nil {
t.Fatalf("failed to create bridge server")
}
mux := http.NewServeMux()
mux.HandleFunc("/bridge/ws", bs.handleWS)
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + "/bridge/ws"
return bs, wsURL
}
func dialWS(t *testing.T, url string, headers http.Header) *websocket.Conn {
t.Helper()
conn, _, err := websocket.DefaultDialer.Dial(url, headers)
if err != nil {
t.Fatalf("dial websocket: %v", err)
}
t.Cleanup(func() { conn.Close() })
return conn
}
func register(t *testing.T, conn *websocket.Conn, platform string, caps []string) {
t.Helper()
msg := map[string]any{
"type": "register",
"platform": platform,
"capabilities": caps,
}
mustWriteJSON(t, conn, msg)
var ack map[string]any
mustReadJSON(t, conn, &ack)
if ack["ok"] != true {
t.Fatalf("register failed: %v", ack["error"])
}
}
func registerWithMetadata(t *testing.T, conn *websocket.Conn, platform string, caps []string, metadata map[string]any) {
t.Helper()
msg := map[string]any{
"type": "register",
"platform": platform,
"capabilities": caps,
"metadata": metadata,
}
mustWriteJSON(t, conn, msg)
var ack map[string]any
mustReadJSON(t, conn, &ack)
if ack["ok"] != true {
t.Fatalf("register failed: %v", ack["error"])
}
}
func readMsg(t *testing.T, conn *websocket.Conn) map[string]any {
t.Helper()
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
t.Fatalf("set read deadline: %v", err)
}
var m map[string]any
if err := conn.ReadJSON(&m); err != nil {
t.Fatalf("read message: %v", err)
}
return m
}
func mustWriteJSON(t *testing.T, conn *websocket.Conn, v any) {
t.Helper()
if err := conn.WriteJSON(v); err != nil {
t.Fatalf("write JSON: %v", err)
}
}
func mustReadJSON(t *testing.T, conn *websocket.Conn, v any) {
t.Helper()
if err := conn.ReadJSON(v); err != nil {
t.Fatalf("read JSON: %v", err)
}
}
func mustDecodeJSON(t *testing.T, r io.Reader, v any) {
t.Helper()
if err := json.NewDecoder(r).Decode(v); err != nil {
t.Fatalf("decode JSON: %v", err)
}
}
func mustEncodeJSON(t *testing.T, w io.Writer, v any) {
t.Helper()
if err := json.NewEncoder(w).Encode(v); err != nil {
t.Fatalf("encode JSON: %v", err)
}
}
func mustUnmarshalJSON(t *testing.T, data []byte, v any) {
t.Helper()
if err := json.Unmarshal(data, v); err != nil {
t.Fatalf("unmarshal JSON: %v", err)
}
}
// tests --------------------------------------------------------------------
func TestBridge_RegisterAndConnect(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
conn := dialWS(t, wsURL, nil)
register(t, conn, "test-chat", []string{"text", "buttons"})
adapters := bs.ConnectedAdapters()
if len(adapters) != 1 || adapters[0] != "test-chat" {
t.Fatalf("expected [test-chat], got %v", adapters)
}
}
func TestBridge_RegisterSendsCapabilitiesSnapshotWhenAdapterSupportsIt(t *testing.T) {
prevVersion, prevCommit, prevBuildTime := CurrentVersion, CurrentCommit, CurrentBuildTime
CurrentVersion = "v2.0.0"
CurrentCommit = "deadbeef"
CurrentBuildTime = "2026-04-11T00:00:00Z"
defer func() {
CurrentVersion = prevVersion
CurrentCommit = prevCommit
CurrentBuildTime = prevBuildTime
}()
bs, wsURL := startTestBridge(t, "")
bp := bs.NewPlatform("test-proj")
e := NewEngine("test-proj", &stubAgent{}, []Platform{bp}, "", LangEnglish)
e.AddCommand("deploy", "Deploy app", "ship it", "", "", "config")
bs.RegisterEngine("test-proj", e, bp)
conn := dialWS(t, wsURL, nil)
registerWithMetadata(t, conn, "bridge", []string{"text"}, map[string]any{
"control_plane": []string{bridgeCapabilitiesSnapshotProto},
})
msg := readMsg(t, conn)
if msg["type"] != bridgeCapabilitiesSnapshotType {
t.Fatalf("type = %v, want %q", msg["type"], bridgeCapabilitiesSnapshotType)
}
if got := int(msg["v"].(float64)); got != 1 {
t.Fatalf("v = %d, want 1", got)
}
host, ok := msg["host"].(map[string]any)
if !ok {
t.Fatalf("host = %T, want object", msg["host"])
}
if host["cc_connect_version"] != "v2.0.0" {
t.Fatalf("cc_connect_version = %v, want %q", host["cc_connect_version"], "v2.0.0")
}
projects, ok := msg["projects"].([]any)
if !ok || len(projects) != 1 {
t.Fatalf("projects = %T/%d, want 1 project", msg["projects"], len(projects))
}
project, ok := projects[0].(map[string]any)
if !ok {
t.Fatalf("project = %T, want object", projects[0])
}
if project["project"] != "test-proj" {
t.Fatalf("project name = %v, want %q", project["project"], "test-proj")
}
commands, ok := project["commands"].([]any)
if !ok || len(commands) == 0 {
t.Fatalf("commands = %T/%d, want non-empty list", project["commands"], len(commands))
}
foundDeploy := false
for _, raw := range commands {
cmd, ok := raw.(map[string]any)
if !ok {
t.Fatalf("command = %T, want object", raw)
}
if cmd["name"] == "deploy" {
foundDeploy = true
}
}
if !foundDeploy {
t.Fatal("expected deploy command in capabilities snapshot")
}
}
func TestBridge_AuthRequired(t *testing.T) {
_, wsURL := startTestBridge(t, "secret123")
// No auth → should fail
_, resp, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err == nil {
t.Fatal("expected connection to be rejected")
}
if resp != nil && resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
// With auth → should succeed
headers := http.Header{}
headers.Set("Authorization", "Bearer secret123")
conn := dialWS(t, wsURL, headers)
register(t, conn, "authed-chat", []string{"text"})
}
func TestBridge_AuthQueryParam(t *testing.T) {
_, wsURL := startTestBridge(t, "qtoken")
conn := dialWS(t, wsURL+"?token=qtoken", nil)
register(t, conn, "qp-chat", []string{"text"})
}
func TestBridge_RegisterMissingPlatform(t *testing.T) {
_, wsURL := startTestBridge(t, "")
conn := dialWS(t, wsURL, nil)
mustWriteJSON(t, conn, map[string]any{
"type": "register",
"platform": "",
"capabilities": []string{"text"},
})
var ack map[string]any
mustReadJSON(t, conn, &ack)
if ack["ok"] == true {
t.Fatal("expected registration to fail for empty platform")
}
}
func TestBridge_MessageRouting(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
var received *Message
var receivedMu sync.Mutex
bp := bs.NewPlatform("test-proj")
e := NewEngine("test-proj", &stubAgent{}, []Platform{bp}, "", LangEnglish)
bs.RegisterEngine("test-proj", e, bp)
bp.handler = func(p Platform, msg *Message) {
receivedMu.Lock()
received = msg
receivedMu.Unlock()
}
conn := dialWS(t, wsURL, nil)
register(t, conn, "mychat", []string{"text"})
imgData := base64.StdEncoding.EncodeToString([]byte("fakepng"))
mustWriteJSON(t, conn, map[string]any{
"type": "message",
"msg_id": "m1",
"session_key": "mychat:user1:user1",
"user_id": "user1",
"user_name": "Alice",
"content": "hello bridge",
"reply_ctx": "conv-1",
"images": []map[string]any{{"mime_type": "image/png", "data": imgData, "file_name": "test.png"}},
})
time.Sleep(100 * time.Millisecond)
receivedMu.Lock()
defer receivedMu.Unlock()
if received == nil {
t.Fatal("expected message to be received")
}
if received.Content != "hello bridge" {
t.Fatalf("content = %q, want %q", received.Content, "hello bridge")
}
if received.Platform != "mychat" {
t.Fatalf("platform = %q, want %q", received.Platform, "mychat")
}
if received.UserName != "Alice" {
t.Fatalf("user_name = %q, want %q", received.UserName, "Alice")
}
if len(received.Images) != 1 {
t.Fatalf("images count = %d, want 1", len(received.Images))
}
if received.Images[0].FileName != "test.png" {
t.Fatalf("image filename = %q, want %q", received.Images[0].FileName, "test.png")
}
}
func TestBridge_MessageReplyCtxCarriesProgressHints(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
gotCh := make(chan *bridgeReplyCtx, 1)
bp := bs.NewPlatform("test-proj")
e := NewEngine("test-proj", &stubAgent{}, []Platform{bp}, "", LangEnglish)
bs.RegisterEngine("test-proj", e, bp)
bp.handler = func(p Platform, msg *Message) {
rc, ok := msg.ReplyCtx.(*bridgeReplyCtx)
if !ok {
t.Fatalf("reply ctx type = %T, want *bridgeReplyCtx", msg.ReplyCtx)
}
gotCh <- rc
}
conn := dialWS(t, wsURL, nil)
registerWithMetadata(t, conn, "bridge", []string{"text", "card", "preview", "update_message"}, map[string]any{
"adapter": "bot-gateway",
})
mustWriteJSON(t, conn, map[string]any{
"type": "message",
"msg_id": "m1",
"session_key": "bridge:room-1:user-1",
"user_id": "user-1",
"content": "hello",
"reply_ctx": "ctx-1",
})
var got *bridgeReplyCtx
select {
case got = <-gotCh:
case <-time.After(5 * time.Second):
t.Fatal("expected reply ctx to be captured")
}
if got.progressStyleHint() != progressStyleCard {
t.Fatalf("progressStyleHint() = %q, want %q", got.progressStyleHint(), progressStyleCard)
}
if !got.supportsProgressCardPayloadHint() {
t.Fatal("supportsProgressCardPayloadHint() = false, want true")
}
}
func TestBridge_ReplyRouting(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
bp := bs.NewPlatform("test-proj")
e := NewEngine("test-proj", &stubAgent{}, []Platform{bp}, "", LangEnglish)
bs.RegisterEngine("test-proj", e, bp)
bp.handler = func(p Platform, msg *Message) {
if err := p.Reply(context.TODO(), msg.ReplyCtx, "pong"); err != nil {
t.Fatalf("Reply: %v", err)
}
}
conn := dialWS(t, wsURL, nil)
register(t, conn, "rc", []string{"text"})
mustWriteJSON(t, conn, map[string]any{
"type": "message",
"msg_id": "m1",
"session_key": "rc:u1:u1",
"user_id": "u1",
"content": "ping",
"reply_ctx": "ctx-1",
})
reply := readMsg(t, conn)
if reply["type"] != "reply" {
t.Fatalf("type = %q, want reply", reply["type"])
}
if reply["content"] != "pong" {
t.Fatalf("content = %q, want pong", reply["content"])
}
if reply["reply_ctx"] != "ctx-1" {
t.Fatalf("reply_ctx = %q, want ctx-1", reply["reply_ctx"])
}
}
func TestBridge_ReconstructReplyCtx_RequiresCapability(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
bp := bs.NewPlatform("advisor-gemini")
conn := dialWS(t, wsURL, nil)
register(t, conn, "bridge", []string{"text"})
_, err := bp.ReconstructReplyCtx("bridge:1491487450722341088:relay")
if err == nil || !strings.Contains(err.Error(), "does not support reconstruct_reply") {
t.Fatalf("ReconstructReplyCtx() error = %v, want reconstruct_reply capability error", err)
}
}
func TestBridge_ReconstructReplyCtx_UsesStructuredPayload(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
bp := bs.NewPlatform("advisor-gemini")
conn := dialWS(t, wsURL, nil)
register(t, conn, "bridge", []string{"text", "reconstruct_reply"})
replyCtx, err := bp.ReconstructReplyCtx("bridge:1491487450722341088:relay")
if err != nil {
t.Fatalf("ReconstructReplyCtx() error = %v", err)
}
rc, ok := replyCtx.(*bridgeReplyCtx)
if !ok {
t.Fatalf("reply ctx type = %T, want *bridgeReplyCtx", replyCtx)
}
if rc.Platform != "bridge" {
t.Fatalf("Platform = %q, want bridge", rc.Platform)
}
if rc.SessionKey != "bridge:1491487450722341088:relay" {
t.Fatalf("SessionKey = %q, want relay session key", rc.SessionKey)
}
var payload bridgeReconstructReplyCtxPayload
if err := json.Unmarshal([]byte(rc.ReplyCtx), &payload); err != nil {
t.Fatalf("unmarshal reply_ctx: %v", err)
}
if payload.Kind != bridgeReconstructReplyCtxKind {
t.Fatalf("kind = %q, want %q", payload.Kind, bridgeReconstructReplyCtxKind)
}
if payload.Version != 1 {
t.Fatalf("version = %d, want 1", payload.Version)
}
if payload.SenderProject != "advisor-gemini" {
t.Fatalf("sender_project = %q, want advisor-gemini", payload.SenderProject)
}
if payload.TransportChatID != "1491487450722341088" {
t.Fatalf("transport_chat_id = %q, want 1491487450722341088", payload.TransportChatID)
}
if payload.TransportSessionKey != "bridge:1491487450722341088:relay" {
t.Fatalf("transport_session_key = %q, want relay session key", payload.TransportSessionKey)
}
}
func TestBridge_ReconstructReplyCtx_UsesAdapterProgressHints(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
bp := bs.NewPlatform("test-proj")
conn := dialWS(t, wsURL, nil)
registerWithMetadata(t, conn, "bridge", []string{"text", "card", "preview", "update_message", "reconstruct_reply"}, map[string]any{
"adapter": "bot-gateway",
})
replyCtx, err := bp.ReconstructReplyCtx("bridge:room-1:user-1")
if err != nil {
t.Fatalf("ReconstructReplyCtx() error = %v", err)
}
rc, ok := replyCtx.(*bridgeReplyCtx)
if !ok {
t.Fatalf("reply ctx type = %T, want *bridgeReplyCtx", replyCtx)
}
if rc.progressStyleHint() != progressStyleCard {
t.Fatalf("progressStyleHint() = %q, want %q", rc.progressStyleHint(), progressStyleCard)
}
if !rc.supportsProgressCardPayloadHint() {
t.Fatal("supportsProgressCardPayloadHint() = false, want true")
}
}
func TestBridge_CardFallback(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
bp := bs.NewPlatform("test-proj")
e := NewEngine("test-proj", &stubAgent{}, []Platform{bp}, "", LangEnglish)
bs.RegisterEngine("test-proj", e, bp)
bp.handler = func(p Platform, msg *Message) {
cs, ok := p.(CardSender)
if !ok {
t.Fatal("BridgePlatform should implement CardSender")
}
card := NewCard().Title("Test", "blue").Markdown("hello").Build()
if err := cs.SendCard(context.TODO(), msg.ReplyCtx, card); err != nil {
t.Fatalf("SendCard: %v", err)
}
}
// Adapter declares NO card capability → should get text fallback
conn := dialWS(t, wsURL, nil)
register(t, conn, "nocards", []string{"text"})
mustWriteJSON(t, conn, map[string]any{
"type": "message",
"msg_id": "m1",
"session_key": "nocards:u1:u1",
"user_id": "u1",
"content": "hi",
"reply_ctx": "c1",
})
reply := readMsg(t, conn)
if reply["type"] != "reply" {
t.Fatalf("expected text fallback, got type=%q", reply["type"])
}
content, _ := reply["content"].(string)
if !strings.Contains(content, "hello") {
t.Fatalf("fallback should contain 'hello', got %q", content)
}
}
func TestBridge_CardNative(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
bp := bs.NewPlatform("test-proj")
e := NewEngine("test-proj", &stubAgent{}, []Platform{bp}, "", LangEnglish)
bs.RegisterEngine("test-proj", e, bp)
bp.handler = func(p Platform, msg *Message) {
cs := p.(CardSender)
card := NewCard().Title("Test", "blue").Markdown("hello").Build()
if err := cs.SendCard(context.TODO(), msg.ReplyCtx, card); err != nil {
t.Fatalf("SendCard: %v", err)
}
}
// Adapter declares card capability → should get card
conn := dialWS(t, wsURL, nil)
register(t, conn, "withcards", []string{"text", "card"})
mustWriteJSON(t, conn, map[string]any{
"type": "message",
"msg_id": "m1",
"session_key": "withcards:u1:u1",
"user_id": "u1",
"content": "hi",
"reply_ctx": "c1",
})
reply := readMsg(t, conn)
if reply["type"] != "card" {
t.Fatalf("expected card, got type=%q", reply["type"])
}
cardData, ok := reply["card"].(map[string]any)
if !ok {
t.Fatal("card field should be a map")
}
header, _ := cardData["header"].(map[string]any)
if header["title"] != "Test" {
t.Fatalf("card title = %q, want Test", header["title"])
}
}
func TestBridge_Ping(t *testing.T) {
_, wsURL := startTestBridge(t, "")
conn := dialWS(t, wsURL, nil)
register(t, conn, "pingtest", []string{"text"})
mustWriteJSON(t, conn, map[string]any{"type": "ping", "ts": time.Now().UnixMilli()})
pong := readMsg(t, conn)
if pong["type"] != "pong" {
t.Fatalf("expected pong, got %q", pong["type"])
}
}
func TestBridge_AdapterReplace(t *testing.T) {
bs, wsURL := startTestBridge(t, "")
conn1 := dialWS(t, wsURL, nil)
register(t, conn1, "replaceme", []string{"text"})
if len(bs.ConnectedAdapters()) != 1 {
t.Fatal("expected 1 adapter")
}
conn2 := dialWS(t, wsURL, nil)
register(t, conn2, "replaceme", []string{"text", "card"})
if len(bs.ConnectedAdapters()) != 1 {
t.Fatal("expected still 1 adapter after replace")
}
a := bs.getAdapter("replaceme")
if !a.capabilities["card"] {
t.Fatal("replaced adapter should have card capability")
}
}
func TestSerializeCard(t *testing.T) {
card := NewCard().
Title("Model", "blue").
Markdown("Choose:").
Buttons(PrimaryBtn("GPT-4", "cmd:/model switch gpt-4"), DefaultBtn("Claude", "cmd:/model switch claude")).
Divider().
Note("tip").
Build()
result := serializeCard(card)
header, _ := result["header"].(map[string]string)
if header["title"] != "Model" || header["color"] != "blue" {
t.Fatalf("header = %v", header)
}
elements, _ := result["elements"].([]map[string]any)
if len(elements) != 4 {
t.Fatalf("elements count = %d, want 4", len(elements))
}
if elements[0]["type"] != "markdown" {
t.Fatalf("first element type = %q", elements[0]["type"])
}
if elements[1]["type"] != "actions" {
t.Fatalf("second element type = %q", elements[1]["type"])
}
if elements[2]["type"] != "divider" {
t.Fatalf("third element type = %q", elements[2]["type"])
}
if elements[3]["type"] != "note" {
t.Fatalf("fourth element type = %q", elements[3]["type"])
}
btns, _ := elements[1]["buttons"].([]map[string]any)
if len(btns) != 2 {
t.Fatalf("buttons count = %d", len(btns))
}
if btns[0]["text"] != "GPT-4" || btns[0]["value"] != "cmd:/model switch gpt-4" {
t.Fatalf("button[0] = %v", btns[0])
}
data, err := json.Marshal(result)
if err != nil {
t.Fatalf("json marshal: %v", err)
}
if len(data) == 0 {
t.Fatal("serialized card is empty")
}
}
// ---------------------------------------------------------------------------
// Session Management REST API tests
// ---------------------------------------------------------------------------
// startTestBridgeWithREST creates a bridge server with both WS and REST endpoints.
func startTestBridgeWithREST(t *testing.T, token string) (*BridgeServer, string) {
t.Helper()
var bs *BridgeServer
if token == "" {
bs = NewBridgeServerInsecure(0, token, "/bridge/ws", nil)
} else {
bs = NewBridgeServer(0, token, "/bridge/ws", nil)
}
if bs == nil {
t.Fatalf("failed to create bridge server")
}
agent := &stubAgent{}
sm := NewSessionManager("")
engine := NewEngine("test-proj", agent, nil, "", LangEnglish)
engine.sessions = sm
bp := bs.NewPlatform("test-proj")
bs.RegisterEngine("test-proj", engine, bp)
mux := http.NewServeMux()
mux.HandleFunc("/bridge/ws", bs.handleWS)
mux.HandleFunc("/bridge/sessions", bs.authHTTP(bs.handleSessions))
mux.HandleFunc("/bridge/sessions/", bs.authHTTP(bs.handleSessionRoutes))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return bs, srv.URL
}
type bridgeAPIResponse struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
func bridgeGet(t *testing.T, url, token string) bridgeAPIResponse {
t.Helper()
req, _ := http.NewRequest("GET", url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("GET %s: %v", url, err)
}
defer resp.Body.Close()
var r bridgeAPIResponse
mustDecodeJSON(t, resp.Body, &r)
return r
}
func bridgePost(t *testing.T, url, token string, body any) bridgeAPIResponse {
t.Helper()
var buf bytes.Buffer
if body != nil {
mustEncodeJSON(t, &buf, body)
}
req, _ := http.NewRequest("POST", url, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST %s: %v", url, err)
}
defer resp.Body.Close()
var r bridgeAPIResponse
mustDecodeJSON(t, resp.Body, &r)
return r
}
func bridgeDel(t *testing.T, url, token string) bridgeAPIResponse {
t.Helper()
req, _ := http.NewRequest("DELETE", url, nil)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("DELETE %s: %v", url, err)
}
defer resp.Body.Close()
var r bridgeAPIResponse
mustDecodeJSON(t, resp.Body, &r)
return r
}
func TestBridge_SessionList(t *testing.T) {
_, baseURL := startTestBridgeWithREST(t, "tok")
// List sessions for a new key — should create a default session
r := bridgeGet(t, baseURL+"/bridge/sessions?session_key=test:u1:u1&token=tok", "")
if !r.OK {
t.Logf("no sessions yet: %s", r.Error)
}
// Create a session first
r = bridgePost(t, baseURL+"/bridge/sessions", "tok", map[string]string{
"session_key": "test:u1:u1",
"name": "work",
})
if !r.OK {
t.Fatalf("create session failed: %s", r.Error)
}
var created struct {
ID string `json:"id"`
Name string `json:"name"`
}
mustUnmarshalJSON(t, r.Data, &created)
if created.ID == "" {
t.Fatal("expected session ID")
}
if created.Name != "work" {
t.Fatalf("expected name 'work', got %q", created.Name)
}
// Now list — should have 1 session
r = bridgeGet(t, baseURL+"/bridge/sessions?session_key=test:u1:u1", "tok")
if !r.OK {
t.Fatalf("list sessions failed: %s", r.Error)
}
var listData struct {
Sessions []map[string]any `json:"sessions"`
}
mustUnmarshalJSON(t, r.Data, &listData)
if len(listData.Sessions) != 1 {
t.Fatalf("expected 1 session, got %d", len(listData.Sessions))
}
}
func TestBridge_SessionCreateAndDetail(t *testing.T) {
_, baseURL := startTestBridgeWithREST(t, "tok")
// Create
r := bridgePost(t, baseURL+"/bridge/sessions", "tok", map[string]string{
"session_key": "test:u1:u1",
"name": "dev",
})
if !r.OK {
t.Fatalf("create failed: %s", r.Error)
}
var created struct {
ID string `json:"id"`
}
mustUnmarshalJSON(t, r.Data, &created)
// Get detail
r = bridgeGet(t, baseURL+"/bridge/sessions/"+created.ID+"?session_key=test:u1:u1", "tok")
if !r.OK {
t.Fatalf("get detail failed: %s", r.Error)
}
var detail struct {
ID string `json:"id"`
Name string `json:"name"`
History []map[string]any `json:"history"`
}
mustUnmarshalJSON(t, r.Data, &detail)
if detail.ID != created.ID {
t.Fatalf("expected id %q, got %q", created.ID, detail.ID)
}
if detail.Name != "dev" {
t.Fatalf("expected name 'dev', got %q", detail.Name)
}
}
func TestBridge_SessionDelete(t *testing.T) {
_, baseURL := startTestBridgeWithREST(t, "tok")
r := bridgePost(t, baseURL+"/bridge/sessions", "tok", map[string]string{
"session_key": "test:u1:u1",
"name": "temp",
})
if !r.OK {
t.Fatalf("create failed: %s", r.Error)
}
var created struct {
ID string `json:"id"`
}
mustUnmarshalJSON(t, r.Data, &created)
// Delete
r = bridgeDel(t, baseURL+"/bridge/sessions/"+created.ID+"?session_key=test:u1:u1", "tok")
if !r.OK {
t.Fatalf("delete failed: %s", r.Error)
}
// Verify deleted
r = bridgeGet(t, baseURL+"/bridge/sessions/"+created.ID+"?session_key=test:u1:u1", "tok")
if r.OK {
t.Fatal("expected 404 after deletion")
}
}
func TestBridge_SessionSwitch(t *testing.T) {
_, baseURL := startTestBridgeWithREST(t, "tok")
// Create two sessions
r := bridgePost(t, baseURL+"/bridge/sessions", "tok", map[string]string{
"session_key": "test:u1:u1",
"name": "first",
})
if !r.OK {
t.Fatalf("create first failed: %s", r.Error)
}
r = bridgePost(t, baseURL+"/bridge/sessions", "tok", map[string]string{
"session_key": "test:u1:u1",
"name": "second",
})
if !r.OK {
t.Fatalf("create second failed: %s", r.Error)
}
var second struct {
ID string `json:"id"`
}
mustUnmarshalJSON(t, r.Data, &second)
// Switch to second
r = bridgePost(t, baseURL+"/bridge/sessions/switch", "tok", map[string]string{
"session_key": "test:u1:u1",
"target": second.ID,
})
if !r.OK {
t.Fatalf("switch failed: %s", r.Error)
}
var switched struct {
ActiveSessionID string `json:"active_session_id"`
}
mustUnmarshalJSON(t, r.Data, &switched)
if switched.ActiveSessionID != second.ID {
t.Fatalf("expected active=%s, got %s", second.ID, switched.ActiveSessionID)
}
}
func TestBridge_SessionAuthRequired(t *testing.T) {
_, baseURL := startTestBridgeWithREST(t, "secret")
r := bridgeGet(t, baseURL+"/bridge/sessions?session_key=test:u1:u1", "")
if r.OK {
t.Fatal("expected auth failure without token")
}
r = bridgeGet(t, baseURL+"/bridge/sessions?session_key=test:u1:u1", "secret")
if !r.OK {
t.Fatalf("expected success with token, got: %s", r.Error)
}
}
func TestBridge_SessionMissingParams(t *testing.T) {
_, baseURL := startTestBridgeWithREST(t, "tok")
// Missing session_key
r := bridgeGet(t, baseURL+"/bridge/sessions", "tok")
if r.OK {
t.Fatal("expected error without session_key")
}
// Missing session_key in POST
r = bridgePost(t, baseURL+"/bridge/sessions", "tok", map[string]string{
"name": "test",
})
if r.OK {
t.Fatal("expected error without session_key in POST")
}
// Missing params in switch
r = bridgePost(t, baseURL+"/bridge/sessions/switch", "tok", map[string]string{
"session_key": "test:u1:u1",
})
if r.OK {
t.Fatal("expected error without target in switch")
}
}