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()) } }