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:
viettranx committed 2026-04-12 21:54:02 +07:00
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)
}
}
+199
View File
@@ -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
}
+76
View File
@@ -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")
}
}
+128
View File
@@ -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,
})
}