341 lines
21 KiB
Go
341 lines
21 KiB
Go
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"},
|
|
{"create hardware SDK", writeCreateConversationError, orchestration.ErrHardwareSDKUpdateRequired, http.StatusUpgradeRequired, "HARDWARE_AGENT_SDK_UPDATE_REQUIRED"},
|
|
{"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())
|
|
}
|
|
}
|