mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 03:13:24 +00:00
test(scenarios): add P2 end-to-end scenario tests
- Add agent conversation scenarios (multi-turn, reset, history) - Add session recovery scenarios (reconnect, fresh, preview) - Add task lifecycle scenarios (create, list, approval) - Include helpers with connect/reconnect/chat utilities
This commit is contained in:
1 parent
6e7e15cf4a
commit
49398208d8
4 files changed
+489
No files matched your search
@@ -0,0 +1,86 @@
|
||||
//go:build integration
|
||||
|
||||
package scenarios
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// SCENARIO: User has multi-turn conversation with context retention.
|
||||
// Flow: User sends message → Agent responds → User follows up → Agent uses context
|
||||
func TestScenario_MultiTurnConversation(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
client := connect(t, wsURL, "")
|
||||
|
||||
// Turn 1: Introduce a topic
|
||||
resp1 := client.chat("My name is Alice and I like pizza.")
|
||||
content1, _ := resp1["content"].(string)
|
||||
if content1 == "" {
|
||||
t.Error("expected non-empty response")
|
||||
}
|
||||
|
||||
// Turn 2: Ask about previously mentioned topic
|
||||
resp2 := client.chat("What is my name?")
|
||||
content2, _ := resp2["content"].(string)
|
||||
|
||||
// Agent should remember the name from context
|
||||
if !strings.Contains(strings.ToLower(content2), "alice") {
|
||||
t.Logf("Response: %s", content2)
|
||||
t.Log("SCENARIO NOTE: Agent may not have retained context - check memory settings")
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO: Session reset clears conversation context.
|
||||
// Flow: Chat → Reset → Chat → Verify no old context
|
||||
func TestScenario_SessionReset(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
client := connect(t, wsURL, "")
|
||||
|
||||
// Establish context
|
||||
client.chat("Remember the secret code: PHOENIX42")
|
||||
|
||||
// Reset session
|
||||
client.send("sessions.reset", map[string]any{
|
||||
"session_key": client.sessionKey,
|
||||
})
|
||||
|
||||
// Check history is empty
|
||||
history := client.getHistory()
|
||||
if len(history) > 0 {
|
||||
t.Errorf("expected empty history after reset, got %d messages", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO: Chat history persists across messages.
|
||||
// Flow: Send 3 messages → Verify history contains all
|
||||
func TestScenario_HistoryPersistence(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
client := connect(t, wsURL, "")
|
||||
|
||||
// Send multiple messages
|
||||
messages := []string{
|
||||
"Message one",
|
||||
"Message two",
|
||||
"Message three",
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
client.chat(msg)
|
||||
}
|
||||
|
||||
// Get history
|
||||
history := client.getHistory()
|
||||
|
||||
// Should have at least user messages (may also have assistant responses)
|
||||
userMsgCount := 0
|
||||
for _, h := range history {
|
||||
if role, _ := h["role"].(string); role == "user" {
|
||||
userMsgCount++
|
||||
}
|
||||
}
|
||||
|
||||
if userMsgCount < len(messages) {
|
||||
t.Errorf("expected at least %d user messages in history, got %d", len(messages), userMsgCount)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
//go:build integration
|
||||
|
||||
// Package scenarios tests end-to-end user journeys.
|
||||
// Scenario tests verify complete workflows, not individual API contracts.
|
||||
package scenarios
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
var (
|
||||
testWSURL string
|
||||
testToken string
|
||||
testUserID string
|
||||
)
|
||||
|
||||
// getTestServer returns WS URL and token from environment.
|
||||
func getTestServer(t *testing.T) (wsURL string, token string) {
|
||||
t.Helper()
|
||||
|
||||
wsURL = os.Getenv("SCENARIO_TEST_WS_URL")
|
||||
if wsURL == "" {
|
||||
wsURL = os.Getenv("CONTRACT_TEST_WS_URL") // fallback
|
||||
}
|
||||
token = os.Getenv("SCENARIO_TEST_TOKEN")
|
||||
if token == "" {
|
||||
token = os.Getenv("CONTRACT_TEST_TOKEN")
|
||||
}
|
||||
|
||||
if wsURL == "" {
|
||||
t.Skip("SCENARIO_TEST_WS_URL not set - skipping scenario test")
|
||||
}
|
||||
if token == "" {
|
||||
t.Skip("SCENARIO_TEST_TOKEN not set - skipping scenario test")
|
||||
}
|
||||
|
||||
testWSURL = wsURL
|
||||
testToken = token
|
||||
testUserID = fmt.Sprintf("scenario-user-%d", time.Now().UnixNano())
|
||||
return wsURL, token
|
||||
}
|
||||
|
||||
// scenarioClient wraps a WebSocket connection for scenario testing.
|
||||
type scenarioClient struct {
|
||||
conn *websocket.Conn
|
||||
nextID int
|
||||
sessionKey string
|
||||
t *testing.T
|
||||
}
|
||||
|
||||
// connect creates a new scenario client with fresh session.
|
||||
func connect(t *testing.T, wsURL string, userID string) *scenarioClient {
|
||||
t.Helper()
|
||||
|
||||
if userID == "" {
|
||||
userID = testUserID
|
||||
}
|
||||
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
||||
conn, _, err := dialer.DialContext(context.Background(), wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
c := &scenarioClient{conn: conn, nextID: 1, t: t}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
// Connect handshake
|
||||
resp := c.send("connect", map[string]any{
|
||||
"token": testToken,
|
||||
"user_id": userID,
|
||||
})
|
||||
|
||||
if sk, ok := resp["session_key"].(string); ok {
|
||||
c.sessionKey = sk
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// reconnect creates a client that resumes an existing session.
|
||||
func reconnect(t *testing.T, wsURL string, sessionKey string, userID string) *scenarioClient {
|
||||
t.Helper()
|
||||
|
||||
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
||||
conn, _, err := dialer.DialContext(context.Background(), wsURL, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
|
||||
c := &scenarioClient{conn: conn, nextID: 1, sessionKey: sessionKey, t: t}
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
|
||||
// Connect with existing session
|
||||
c.send("connect", map[string]any{
|
||||
"token": testToken,
|
||||
"user_id": userID,
|
||||
"session_key": sessionKey,
|
||||
})
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
// send sends a request and waits for response.
|
||||
func (c *scenarioClient) send(method string, params any) map[string]any {
|
||||
c.t.Helper()
|
||||
|
||||
reqID := fmt.Sprintf("req-%d", c.nextID)
|
||||
c.nextID++
|
||||
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
req := protocol.RequestFrame{
|
||||
Type: protocol.FrameTypeRequest,
|
||||
ID: reqID,
|
||||
Method: method,
|
||||
Params: paramsJSON,
|
||||
}
|
||||
|
||||
if err := c.conn.WriteJSON(req); err != nil {
|
||||
c.t.Fatalf("send %s: %v", method, err)
|
||||
}
|
||||
|
||||
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
var resp protocol.ResponseFrame
|
||||
if err := c.conn.ReadJSON(&resp); err != nil {
|
||||
c.t.Fatalf("read %s response: %v", method, err)
|
||||
}
|
||||
|
||||
if resp.ID != reqID {
|
||||
c.t.Fatalf("response ID mismatch: got %s, want %s", resp.ID, reqID)
|
||||
}
|
||||
if !resp.OK {
|
||||
c.t.Fatalf("%s failed: %s - %s", method, resp.Error.Code, resp.Error.Message)
|
||||
}
|
||||
|
||||
payloadJSON, _ := json.Marshal(resp.Payload)
|
||||
var result map[string]any
|
||||
json.Unmarshal(payloadJSON, &result)
|
||||
return result
|
||||
}
|
||||
|
||||
// sendExpectError sends a request expecting an error response.
|
||||
func (c *scenarioClient) sendExpectError(method string, params any) (code string, message string) {
|
||||
c.t.Helper()
|
||||
|
||||
reqID := fmt.Sprintf("req-%d", c.nextID)
|
||||
c.nextID++
|
||||
|
||||
paramsJSON, _ := json.Marshal(params)
|
||||
req := protocol.RequestFrame{
|
||||
Type: protocol.FrameTypeRequest,
|
||||
ID: reqID,
|
||||
Method: method,
|
||||
Params: paramsJSON,
|
||||
}
|
||||
|
||||
if err := c.conn.WriteJSON(req); err != nil {
|
||||
c.t.Fatalf("send %s: %v", method, err)
|
||||
}
|
||||
|
||||
c.conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
||||
var resp protocol.ResponseFrame
|
||||
if err := c.conn.ReadJSON(&resp); err != nil {
|
||||
c.t.Fatalf("read %s response: %v", method, err)
|
||||
}
|
||||
|
||||
if resp.OK {
|
||||
c.t.Fatalf("expected error but got success for %s", method)
|
||||
}
|
||||
|
||||
return resp.Error.Code, resp.Error.Message
|
||||
}
|
||||
|
||||
// chat sends a chat message and returns the response.
|
||||
func (c *scenarioClient) chat(message string) map[string]any {
|
||||
return c.send("chat.send", map[string]any{
|
||||
"message": message,
|
||||
})
|
||||
}
|
||||
|
||||
// getHistory retrieves chat history for current session.
|
||||
func (c *scenarioClient) getHistory() []map[string]any {
|
||||
resp := c.send("chat.history", map[string]any{})
|
||||
messages, _ := resp["messages"].([]any)
|
||||
result := make([]map[string]any, len(messages))
|
||||
for i, m := range messages {
|
||||
result[i], _ = m.(map[string]any)
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
//go:build integration
|
||||
|
||||
package scenarios
|
||||
|
||||
import "testing"
|
||||
|
||||
// SCENARIO: User reconnects and resumes session with history.
|
||||
// Flow: Connect → Chat → Disconnect → Reconnect with session_key → History preserved
|
||||
func TestScenario_ReconnectWithHistory(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
userID := "reconnect-test-user"
|
||||
|
||||
// First connection
|
||||
client1 := connect(t, wsURL, userID)
|
||||
sessionKey := client1.sessionKey
|
||||
|
||||
if sessionKey == "" {
|
||||
t.Skip("No session_key returned - reconnect test not applicable")
|
||||
}
|
||||
|
||||
// Send a message
|
||||
client1.chat("Remember this for reconnect test")
|
||||
|
||||
// Get history before disconnect
|
||||
history1 := client1.getHistory()
|
||||
msgCount1 := len(history1)
|
||||
|
||||
// Simulate disconnect (close connection)
|
||||
client1.conn.Close()
|
||||
|
||||
// Reconnect with same session
|
||||
client2 := reconnect(t, wsURL, sessionKey, userID)
|
||||
|
||||
// History should be preserved
|
||||
history2 := client2.getHistory()
|
||||
|
||||
if len(history2) < msgCount1 {
|
||||
t.Errorf("history not preserved: before=%d, after=%d", msgCount1, len(history2))
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO: New session starts fresh without history.
|
||||
// Flow: Connect with new user → No existing history
|
||||
func TestScenario_FreshSession(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
userID := "fresh-session-test-user"
|
||||
|
||||
client := connect(t, wsURL, userID)
|
||||
|
||||
// Fresh session should have no history
|
||||
history := client.getHistory()
|
||||
|
||||
if len(history) > 0 {
|
||||
t.Logf("Fresh session has %d messages - may be resuming existing session", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO: Session preview returns summary without full history.
|
||||
func TestScenario_SessionPreview(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
client := connect(t, wsURL, "")
|
||||
|
||||
// Send some messages
|
||||
client.chat("First message for preview test")
|
||||
client.chat("Second message for preview test")
|
||||
|
||||
// Get session preview
|
||||
resp := client.send("sessions.preview", map[string]any{
|
||||
"session_key": client.sessionKey,
|
||||
})
|
||||
|
||||
// Should have some preview data
|
||||
if resp["title"] == nil && resp["preview"] == nil && resp["message_count"] == nil {
|
||||
t.Log("sessions.preview returned no standard fields - check implementation")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//go:build integration
|
||||
|
||||
package scenarios
|
||||
|
||||
import "testing"
|
||||
|
||||
// SCENARIO: Task happy path - create, claim, complete.
|
||||
// Flow: Create task → Assign → Update progress → Complete
|
||||
func TestScenario_TaskHappyPath(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
client := connect(t, wsURL, "")
|
||||
|
||||
// First check if teams exist
|
||||
teamsResp := client.send("teams.list", map[string]any{})
|
||||
teams, ok := teamsResp["teams"].([]any)
|
||||
if !ok || len(teams) == 0 {
|
||||
t.Skip("No teams available - skipping task lifecycle test")
|
||||
}
|
||||
|
||||
team := teams[0].(map[string]any)
|
||||
teamID, _ := team["id"].(string)
|
||||
|
||||
// Create a task
|
||||
createResp := client.send("teams.tasks.create", map[string]any{
|
||||
"team_id": teamID,
|
||||
"title": "Test task for scenario",
|
||||
"description": "Created by scenario test",
|
||||
})
|
||||
|
||||
taskID, ok := createResp["id"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("task creation failed, no id returned")
|
||||
}
|
||||
|
||||
// Get task to verify
|
||||
getResp := client.send("teams.tasks.get", map[string]any{
|
||||
"team_id": teamID,
|
||||
"task_id": taskID,
|
||||
})
|
||||
|
||||
status, _ := getResp["status"].(string)
|
||||
if status == "" {
|
||||
t.Error("task should have a status")
|
||||
}
|
||||
|
||||
// Clean up - delete task
|
||||
client.send("teams.tasks.delete", map[string]any{
|
||||
"team_id": teamID,
|
||||
"task_id": taskID,
|
||||
})
|
||||
}
|
||||
|
||||
// SCENARIO: Task list returns tasks for team.
|
||||
func TestScenario_TaskList(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
client := connect(t, wsURL, "")
|
||||
|
||||
// Check teams
|
||||
teamsResp := client.send("teams.list", map[string]any{})
|
||||
teams, ok := teamsResp["teams"].([]any)
|
||||
if !ok || len(teams) == 0 {
|
||||
t.Skip("No teams available - skipping task list test")
|
||||
}
|
||||
|
||||
team := teams[0].(map[string]any)
|
||||
teamID, _ := team["id"].(string)
|
||||
|
||||
// List tasks
|
||||
listResp := client.send("teams.tasks.list", map[string]any{
|
||||
"team_id": teamID,
|
||||
})
|
||||
|
||||
// Should return array (may be empty)
|
||||
if _, ok := listResp["tasks"].([]any); !ok {
|
||||
t.Error("tasks field should be an array")
|
||||
}
|
||||
}
|
||||
|
||||
// SCENARIO: Task approval workflow.
|
||||
// Flow: Create task → Approve → Verify status change
|
||||
func TestScenario_TaskApproval(t *testing.T) {
|
||||
wsURL, _ := getTestServer(t)
|
||||
client := connect(t, wsURL, "")
|
||||
|
||||
// Check teams
|
||||
teamsResp := client.send("teams.list", map[string]any{})
|
||||
teams, ok := teamsResp["teams"].([]any)
|
||||
if !ok || len(teams) == 0 {
|
||||
t.Skip("No teams available - skipping task approval test")
|
||||
}
|
||||
|
||||
team := teams[0].(map[string]any)
|
||||
teamID, _ := team["id"].(string)
|
||||
|
||||
// Create task
|
||||
createResp := client.send("teams.tasks.create", map[string]any{
|
||||
"team_id": teamID,
|
||||
"title": "Task for approval test",
|
||||
"description": "Will be approved",
|
||||
})
|
||||
|
||||
taskID, ok := createResp["id"].(string)
|
||||
if !ok {
|
||||
t.Fatalf("task creation failed")
|
||||
}
|
||||
|
||||
// Approve task
|
||||
client.send("teams.tasks.approve", map[string]any{
|
||||
"team_id": teamID,
|
||||
"task_id": taskID,
|
||||
"comment": "Approved by scenario test",
|
||||
})
|
||||
|
||||
// Verify status changed
|
||||
getResp := client.send("teams.tasks.get", map[string]any{
|
||||
"team_id": teamID,
|
||||
"task_id": taskID,
|
||||
})
|
||||
|
||||
status, _ := getResp["status"].(string)
|
||||
t.Logf("Task status after approval: %s", status)
|
||||
|
||||
// Clean up
|
||||
client.send("teams.tasks.delete", map[string]any{
|
||||
"team_id": teamID,
|
||||
"task_id": taskID,
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user