Files

341 lines
21 KiB
Go
Raw Permalink Normal View History

2026-08-26 06:41:07 +08:00
package httpapi
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"data-terminal-cloud/internal/deviceauth"
"data-terminal-cloud/internal/idempotency"
"data-terminal-cloud/internal/orchestration"
)
type fakeConversationCreator struct {
response orchestration.CreateResponse
replayed bool
err error
calls int
}
type fakeConversationActivator struct {
response orchestration.ActivateResponse
replayed bool
err error
calls int
}
type fakeDeviceConversationStateReader struct {
response orchestration.DeviceConversationState
err error
calls int
}
type fakeConversationHeartbeater struct {
response orchestration.HeartbeatResponse
err error
calls int
}
type fakeConversationStopper struct {
response orchestration.StopResponse
replayed bool
err error
calls int
}
type fakeConversationTranscriptSubmitter struct {
response orchestration.TranscriptBatchResponse
replayed bool
err error
calls int
}
func (s *fakeConversationTranscriptSubmitter) Submit(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.TranscriptBatchInput) (orchestration.TranscriptBatchResponse, bool, error) {
s.calls++
return s.response, s.replayed, s.err
}
func (s *fakeConversationStopper) Stop(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.StopInput) (orchestration.StopResponse, bool, error) {
s.calls++
return s.response, s.replayed, s.err
}
func (h *fakeConversationHeartbeater) Heartbeat(context.Context, deviceauth.Identity, string, orchestration.HeartbeatInput) (orchestration.HeartbeatResponse, error) {
h.calls++
return h.response, h.err
}
func (a *fakeConversationActivator) Activate(context.Context, deviceauth.Identity, string, string, string, []byte, orchestration.ActivateInput) (orchestration.ActivateResponse, bool, error) {
a.calls++
return a.response, a.replayed, a.err
}
func (r *fakeDeviceConversationStateReader) GetDeviceState(context.Context, deviceauth.Identity, string) (orchestration.DeviceConversationState, error) {
r.calls++
return r.response, r.err
}
func (c *fakeConversationCreator) Create(context.Context, deviceauth.Identity, string, string, []byte, orchestration.CreateInput) (orchestration.CreateResponse, bool, error) {
c.calls++
return c.response, c.replayed, c.err
}
func TestCreateConversationValidatesAndReturnsReplayMarker(t *testing.T) {
testKey := "test-idempotency-key-0001"
creator := &fakeConversationCreator{response: orchestration.CreateResponse{ConversationID: "10000000-0000-0000-0000-000000009999", Status: "ROOM_ISSUED"}, replayed: true}
handler := CreateConversationHandler(creator)
body := `{"device_time":"2026-08-23T12:00:00Z","capabilities":{"rtc":true,"audio_tracks":["near","far"],"upload_resume":true},"network":{"type":"wifi"},"requested_goal":"open_conversation"}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", strings.NewReader(body))
request.Header.Set("Idempotency-Key", testKey)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusCreated || recorder.Header().Get("Idempotency-Replayed") != "true" || creator.calls != 1 {
t.Fatalf("status=%d replay=%q calls=%d body=%s", recorder.Code, recorder.Header().Get("Idempotency-Replayed"), creator.calls, recorder.Body.String())
}
}
func TestConversationErrorWritersExposeStableStateAndRetryContracts(t *testing.T) {
type errorWriter func(http.ResponseWriter, *http.Request, error)
tests := []struct {
name string
writer errorWriter
err error
status int
code string
}{
{"create unbound", writeCreateConversationError, orchestration.ErrDeviceNotBound, http.StatusConflict, "DEVICE_NOT_BOUND"},
{"create consent", writeCreateConversationError, orchestration.ErrConsentRequired, http.StatusForbidden, "CONSENT_REQUIRED"},
{"create firmware", writeCreateConversationError, orchestration.ErrFirmwareUpdateRequired, http.StatusUpgradeRequired, "FIRMWARE_UPDATE_REQUIRED"},
2026-09-06 17:04:11 +08:00
{"create hardware SDK", writeCreateConversationError, orchestration.ErrHardwareSDKUpdateRequired, http.StatusUpgradeRequired, "HARDWARE_AGENT_SDK_UPDATE_REQUIRED"},
2026-08-26 06:41:07 +08:00
{"create active", writeCreateConversationError, orchestration.ErrConversationAlreadyActive, http.StatusConflict, "CONVERSATION_ALREADY_ACTIVE"},
{"create quota", writeCreateConversationError, orchestration.ErrUsageQuotaExceeded, http.StatusTooManyRequests, "USAGE_QUOTA_EXCEEDED"},
{"create runtime config", writeCreateConversationError, orchestration.ErrRuntimeConfigUnavailable, http.StatusServiceUnavailable, "RUNTIME_CONFIG_UNAVAILABLE"},
{"create context preparation", writeCreateConversationError, &orchestration.CreateFailureError{ConversationID: "10000000-0000-0000-0000-000000009998", FailureCode: "context_preparation_failed"}, http.StatusUnprocessableEntity, "AGENT_CONTEXT_PREPARATION_FAILED"},
{"create key conflict", writeCreateConversationError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"create in progress", writeCreateConversationError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"create dependency", writeCreateConversationError, errors.New("database unavailable"), http.StatusServiceUnavailable, "CONVERSATION_CREATE_UNAVAILABLE"},
{"activate missing", writeActivateConversationError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"activate expired", writeActivateConversationError, orchestration.ErrAllocationExpired, http.StatusConflict, "ROOM_ALLOCATION_EXPIRED"},
{"activate rtc mismatch", writeActivateConversationError, orchestration.ErrRTCIdentityMismatch, http.StatusConflict, "RTC_ALLOCATION_MISMATCH"},
{"activate config", writeActivateConversationError, orchestration.ErrConfigVersionConflict, http.StatusConflict, "CONFIG_VERSION_CONFLICT"},
{"activate state", writeActivateConversationError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"activate key conflict", writeActivateConversationError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"activate in progress", writeActivateConversationError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"activate dependency", writeActivateConversationError, errors.New("database unavailable"), http.StatusServiceUnavailable, "CONVERSATION_ACTIVATE_UNAVAILABLE"},
{"heartbeat missing", writeHeartbeatError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"heartbeat config", writeHeartbeatError, orchestration.ErrConfigVersionConflict, http.StatusConflict, "CONFIG_VERSION_CONFLICT"},
{"heartbeat state", writeHeartbeatError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"heartbeat dependency", writeHeartbeatError, errors.New("redis unavailable"), http.StatusServiceUnavailable, "CONVERSATION_HEARTBEAT_UNAVAILABLE"},
{"stop missing", writeStopConversationError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"stop state", writeStopConversationError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"stop key conflict", writeStopConversationError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"stop in progress", writeStopConversationError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"stop dependency", writeStopConversationError, errors.New("database unavailable"), http.StatusServiceUnavailable, "CONVERSATION_STOP_UNAVAILABLE"},
{"transcript missing", writeTranscriptBatchError, orchestration.ErrConversationNotFound, http.StatusNotFound, "CONVERSATION_NOT_FOUND"},
{"transcript state", writeTranscriptBatchError, orchestration.ErrConversationStateConflict, http.StatusConflict, "CONVERSATION_STATE_CONFLICT"},
{"transcript consent", writeTranscriptBatchError, orchestration.ErrTranscriptStorageNotAllowed, http.StatusForbidden, "TRANSCRIPT_STORAGE_NOT_ALLOWED"},
{"transcript key conflict", writeTranscriptBatchError, idempotency.ErrKeyConflict, http.StatusConflict, "IDEMPOTENCY_KEY_REUSED"},
{"transcript in progress", writeTranscriptBatchError, idempotency.ErrInProgress, http.StatusConflict, "REQUEST_IN_PROGRESS"},
{"transcript dependency", writeTranscriptBatchError, errors.New("database unavailable"), http.StatusServiceUnavailable, "TRANSCRIPT_BATCH_UNAVAILABLE"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", nil)
test.writer(recorder, request, test.err)
if recorder.Code != test.status || !strings.Contains(recorder.Body.String(), test.code) {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
})
}
}
func TestCreateConversationReplaysPersistedPreparationFailure(t *testing.T) {
creator := &fakeConversationCreator{
replayed: true,
err: &orchestration.CreateFailureError{
ConversationID: "10000000-0000-0000-0000-000000009998",
FailureCode: "context_preparation_failed",
},
}
handler := CreateConversationHandler(creator)
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", strings.NewReader(`{"device_time":"2026-08-23T12:00:00Z","capabilities":{"rtc":true,"audio_tracks":["near","far"],"upload_resume":true},"network":{"type":"wifi"},"requested_goal":"open_conversation"}`))
request.Header.Set("Idempotency-Key", "test-idempotency-key-failure")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity || recorder.Header().Get("Idempotency-Replayed") != "true" || !strings.Contains(recorder.Body.String(), `"failure_code":"context_preparation_failed"`) {
t.Fatalf("status=%d replay=%q body=%s", recorder.Code, recorder.Header().Get("Idempotency-Replayed"), recorder.Body.String())
}
}
func TestCreateConversationRejectsUnknownAndMissingFields(t *testing.T) {
testKey := "test-idempotency-key-0002"
creator := &fakeConversationCreator{}
for _, body := range []string{
`{"unexpected":true}`,
`{"device_time":"2026-08-23T12:00:00Z","capabilities":{"rtc":false,"audio_tracks":[],"upload_resume":true},"network":{"type":""},"requested_goal":"other"}`,
} {
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations", strings.NewReader(body))
request.Header.Set("Idempotency-Key", testKey)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler := CreateConversationHandler(creator)
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("body=%s status=%d response=%s", body, recorder.Code, recorder.Body.String())
}
}
if creator.calls != 0 {
t.Fatalf("invalid requests reached service %d times", creator.calls)
}
}
func TestActivateConversationAcceptsRTCConfirmation(t *testing.T) {
testKey := "test-activate-idempotency-0001"
activator := &fakeConversationActivator{response: orchestration.ActivateResponse{
ConversationID: "10000000-0000-0000-0000-000000009999", Status: "STARTING_AGENT", AcceptedAt: time.Now(),
}}
handler := ActivateConversationHandler(activator)
body := `{"joined_at":"2026-08-23T12:00:00Z","room_id":"room-1","rtc_user_id":"device-1","rtc_join_result":"success","config_revision":1}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/activate", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request.Header.Set("Idempotency-Key", testKey)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted || activator.calls != 1 || !containsText(recorder.Body.String(), "STARTING_AGENT") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, activator.calls, recorder.Body.String())
}
}
func TestGetDeviceConversationStateReturnsActionAndStableFailure(t *testing.T) {
conversationID := "10000000-0000-0000-0000-000000009999"
reader := &fakeDeviceConversationStateReader{response: orchestration.DeviceConversationState{
ConversationID: conversationID, Status: "FAILED", Action: "leave_room", FailureCode: "provider_start_rejected",
}}
handler := GetDeviceConversationStateHandler(reader)
request := httptest.NewRequest(http.MethodGet, "/device/v2/conversations/"+conversationID, nil)
request.SetPathValue("id", conversationID)
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || reader.calls != 1 || !containsText(recorder.Body.String(), "provider_start_rejected") || !containsText(recorder.Body.String(), "leave_room") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, reader.calls, recorder.Body.String())
}
invalid := httptest.NewRequest(http.MethodGet, "/device/v2/conversations/not-a-uuid", nil)
invalid.SetPathValue("id", "not-a-uuid")
invalid = invalid.WithContext(deviceauth.WithIdentity(invalid.Context(), deviceauth.Identity{PublicID: "device-1"}))
invalidRecorder := httptest.NewRecorder()
handler.ServeHTTP(invalidRecorder, invalid)
if invalidRecorder.Code != http.StatusBadRequest || reader.calls != 1 {
t.Fatalf("invalid status=%d calls=%d body=%s", invalidRecorder.Code, reader.calls, invalidRecorder.Body.String())
}
reader.err = orchestration.ErrConversationNotFound
missing := httptest.NewRequest(http.MethodGet, "/device/v2/conversations/"+conversationID, nil)
missing.SetPathValue("id", conversationID)
missing = missing.WithContext(deviceauth.WithIdentity(missing.Context(), deviceauth.Identity{PublicID: "device-1"}))
missingRecorder := httptest.NewRecorder()
handler.ServeHTTP(missingRecorder, missing)
if missingRecorder.Code != http.StatusNotFound || reader.calls != 2 {
t.Fatalf("missing status=%d calls=%d body=%s", missingRecorder.Code, reader.calls, missingRecorder.Body.String())
}
}
func TestHeartbeatConversationValidatesRequiredRuntimeState(t *testing.T) {
expiresAt := time.Now().Add(time.Minute)
heartbeater := &fakeConversationHeartbeater{response: orchestration.HeartbeatResponse{
LeaseExpiresAt: &expiresAt, Actions: []orchestration.HeartbeatAction{{Type: "continue"}},
DesiredConfigRevision: 1,
}}
handler := HeartbeatConversationHandler(heartbeater)
body := `{"sequence_no":28,"device_time":"2026-08-23T12:00:00Z","monotonic_ms":86234511,"conversation_status":"ACTIVE","rtc":{"joined":true,"uplink":true,"downlink":true},"network":{"type":"wifi","rssi":-62},"firmware_version":"1.0.0","reported_config_revision":1,"recording_queue":{"pending_segments":4,"failed_segments":1,"last_failure_code":"MEDIA_UPLOAD_TIMEOUT","last_failure_at":"2026-08-23T11:59:58Z"},"rtc_credential_expires_at":"2026-08-23T12:05:00Z","command_results":[]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/heartbeat", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || heartbeater.calls != 1 || !containsText(recorder.Body.String(), "continue") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, heartbeater.calls, recorder.Body.String())
}
invalid := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/id/heartbeat", strings.NewReader(`{"sequence_no":0}`))
invalid = invalid.WithContext(deviceauth.WithIdentity(invalid.Context(), deviceauth.Identity{PublicID: "device-1"}))
invalidRecorder := httptest.NewRecorder()
handler.ServeHTTP(invalidRecorder, invalid)
if invalidRecorder.Code != http.StatusBadRequest || heartbeater.calls != 1 {
t.Fatalf("invalid status=%d calls=%d body=%s", invalidRecorder.Code, heartbeater.calls, invalidRecorder.Body.String())
}
}
func TestStopConversationAcceptsSafeIdempotentRequest(t *testing.T) {
stopper := &fakeConversationStopper{response: orchestration.StopResponse{
ConversationID: "10000000-0000-0000-0000-000000009999", Status: "STOPPING", AcceptedAt: time.Now(),
}, replayed: true}
handler := StopConversationHandler(stopper)
body := `{"reason":"handset_on_hook","device_time":"2026-08-23T12:00:00Z","last_heartbeat_sequence":28,"rtc_left":true,"recording":{"near_segments":18,"far_segments":17}}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/stop", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request.Header.Set("Idempotency-Key", "stop-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusAccepted || stopper.calls != 1 || recorder.Header().Get("Idempotency-Replayed") != "true" || !containsText(recorder.Body.String(), "STOPPING") {
t.Fatalf("status=%d calls=%d replay=%q body=%s", recorder.Code, stopper.calls, recorder.Header().Get("Idempotency-Replayed"), recorder.Body.String())
}
}
func TestSubmitConversationTurnsValidatesAndReturnsReplayMarker(t *testing.T) {
submitter := &fakeConversationTranscriptSubmitter{response: orchestration.TranscriptBatchResponse{
ConversationID: "10000000-0000-0000-0000-000000009999", AcceptedCount: 1, DuplicateCount: 1,
}, replayed: true}
handler := SubmitConversationTurnsHandler(submitter)
body := `{"turns":[{"turn_key":"device-turn-1","sequence_no":3,"speaker":"subject","start_at":"2026-08-23T12:00:00Z","end_at":"2026-08-23T12:00:02Z","final_text":"您好","interrupted":false}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/10000000-0000-0000-0000-000000009999/turns:batch", strings.NewReader(body))
request.SetPathValue("id", "10000000-0000-0000-0000-000000009999")
request.Header.Set("Idempotency-Key", "turns-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK || submitter.calls != 1 || recorder.Header().Get("Idempotency-Replayed") != "true" || !containsText(recorder.Body.String(), "accepted_count") {
t.Fatalf("status=%d calls=%d replay=%q body=%s", recorder.Code, submitter.calls, recorder.Header().Get("Idempotency-Replayed"), recorder.Body.String())
}
}
func TestSubmitConversationTurnsRejectsDuplicateKeysAndInvalidRanges(t *testing.T) {
submitter := &fakeConversationTranscriptSubmitter{}
handler := SubmitConversationTurnsHandler(submitter)
body := `{"turns":[{"turn_key":"same","sequence_no":1,"speaker":"unknown","start_at":"2026-08-23T12:00:02Z","end_at":"2026-08-23T12:00:00Z","final_text":"","interrupted":false},{"turn_key":"same","sequence_no":1,"speaker":"agent","start_at":"2026-08-23T12:00:00Z","end_at":"2026-08-23T12:00:01Z","final_text":"ok","interrupted":false}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/id/turns:batch", strings.NewReader(body))
request.Header.Set("Idempotency-Key", "turns-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest || submitter.calls != 0 || !containsText(recorder.Body.String(), "请求内不能重复") {
t.Fatalf("status=%d calls=%d body=%s", recorder.Code, submitter.calls, recorder.Body.String())
}
}
func TestSubmitConversationTurnsReturnsStructuredConflict(t *testing.T) {
submitter := &fakeConversationTranscriptSubmitter{err: &orchestration.TranscriptConflictError{Conflicts: []orchestration.TranscriptConflict{{
TurnKey: "turn-1", SequenceNo: 1, Reason: "TURN_KEY_OR_SEQUENCE_ALREADY_USED",
}}}}
handler := SubmitConversationTurnsHandler(submitter)
body := `{"turns":[{"turn_key":"turn-1","sequence_no":1,"speaker":"agent","start_at":"2026-08-23T12:00:00Z","end_at":"2026-08-23T12:00:01Z","final_text":"different","interrupted":false}]}`
request := httptest.NewRequest(http.MethodPost, "/device/v2/conversations/id/turns:batch", strings.NewReader(body))
request.Header.Set("Idempotency-Key", "turns-0123456789abcdef")
request = request.WithContext(deviceauth.WithIdentity(request.Context(), deviceauth.Identity{PublicID: "device-1"}))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusConflict || !containsText(recorder.Body.String(), "TRANSCRIPT_TURN_CONFLICT") || !containsText(recorder.Body.String(), "TURN_KEY_OR_SEQUENCE_ALREADY_USED") {
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
}
}