package httpapi import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/concurrency" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/saas" "github.com/jackc/pgx/v5/pgxpool" ) func TestSaaSHouseholdAndSubjectWriteHandlers(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } pool, err := pgxpool.New(context.Background(), databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service := saas.NewService(pool, audit.NewRepository(), nil, nil) principal := identity.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"} var householdID, subjectID, factID, consentID string defer func() { if subjectID != "" { if consentID != "" { _, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, consentID) } if factID != "" { _, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, factID) } _, _ = pool.Exec(context.Background(), `DELETE FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(context.Background(), `DELETE FROM consent_record WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(context.Background(), `DELETE FROM subject_fact WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, subjectID) _, _ = pool.Exec(context.Background(), `DELETE FROM subject_profile WHERE public_id=$1::uuid`, subjectID) } if householdID != "" { _, _ = pool.Exec(context.Background(), `DELETE FROM audit_event WHERE resource_id=$1::uuid`, householdID) _, _ = pool.Exec(context.Background(), `DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, householdID) _, _ = pool.Exec(context.Background(), `DELETE FROM household WHERE public_id=$1::uuid`, householdID) } }() request := authenticatedRequest(http.MethodPost, "/api/v1/households", `{"name":"HTTP写接口集成家庭"}`, principal) request.Header.Set("X-Tenant-Id", "10000000-0000-0000-0000-000000000010") recorder := httptest.NewRecorder() CreateHouseholdHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) { t.Fatalf("create household status=%d headers=%v body=%s", recorder.Code, recorder.Header(), recorder.Body.String()) } var householdResponse struct { Data saas.Household `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &householdResponse); err != nil { t.Fatal(err) } householdID = householdResponse.Data.ID request = authenticatedRequest(http.MethodPatch, "/api/v1/households/"+householdID, `{"name":"HTTP写接口集成家庭-修改"}`, principal) request.SetPathValue("id", householdID) recorder = httptest.NewRecorder() PatchHouseholdHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusPreconditionRequired { t.Fatalf("missing household ETag status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodPatch, "/api/v1/households/"+householdID, `{"name":"HTTP写接口集成家庭-修改"}`, principal) request.SetPathValue("id", householdID) request.Header.Set("If-Match", concurrency.FormatETag(1)) recorder = httptest.NewRecorder() PatchHouseholdHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) { t.Fatalf("patch household status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/subjects", `{"preferred_name":"HTTP集成长者","language":"zh-CN","timezone":"Asia/Shanghai","profile_data":{"synthetic":true},"conversation_preferences":{"pace":"slow"}}`, principal) request.SetPathValue("id", householdID) recorder = httptest.NewRecorder() CreateSubjectHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) { t.Fatalf("create subject status=%d body=%s", recorder.Code, recorder.Body.String()) } var subjectResponse struct { Data saas.Subject `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &subjectResponse); err != nil { t.Fatal(err) } subjectID = subjectResponse.Data.ID request = authenticatedRequest(http.MethodPatch, "/api/v1/subjects/"+subjectID, `{"preferred_name":"HTTP集成长者-修改"}`, principal) request.SetPathValue("id", subjectID) request.Header.Set("If-Match", concurrency.FormatETag(1)) recorder = httptest.NewRecorder() PatchSubjectHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) { t.Fatalf("patch subject status=%d body=%s", recorder.Code, recorder.Body.String()) } var patchedSubject struct { Data saas.Subject `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &patchedSubject); err != nil || patchedSubject.Data.PreferredName != "HTTP集成长者-修改" { t.Fatalf("patched subject=%+v err=%v", patchedSubject, err) } request = authenticatedRequest(http.MethodPost, "/api/v1/subjects/"+subjectID+"/facts", `{"fact_type":"hometown","content":"合成城市","source_type":"family_entry","source_ref":"http-integration://source","confirmation_status":"pending","sensitivity":"normal"}`, principal) request.SetPathValue("id", subjectID) recorder = httptest.NewRecorder() CreateSubjectFactHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated || recorder.Header().Get("ETag") != concurrency.FormatETag(1) { t.Fatalf("create fact status=%d body=%s", recorder.Code, recorder.Body.String()) } var factResponse struct { Data saas.SubjectFact `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &factResponse); err != nil { t.Fatal(err) } factID = factResponse.Data.ID request = authenticatedRequest(http.MethodPatch, "/api/v1/subjects/"+subjectID+"/facts/"+factID, `{"confirmation_status":"confirmed"}`, principal) request.SetPathValue("id", subjectID) request.SetPathValue("fact_id", factID) request.Header.Set("If-Match", concurrency.FormatETag(1)) recorder = httptest.NewRecorder() PatchSubjectFactHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK || recorder.Header().Get("ETag") != concurrency.FormatETag(2) { t.Fatalf("patch fact status=%d body=%s", recorder.Code, recorder.Body.String()) } request = authenticatedRequest(http.MethodPost, "/api/v1/subjects/"+subjectID+"/consents", `{"type":"story_extraction","scope":{"future":true},"policy_version":"local-v1","notice_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","evidence_ref":"http-integration://consent"}`, principal) request.SetPathValue("id", subjectID) recorder = httptest.NewRecorder() GrantConsentHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusCreated { t.Fatalf("grant consent status=%d body=%s", recorder.Code, recorder.Body.String()) } var consentResponse struct { Data saas.Consent `json:"data"` } if err := json.Unmarshal(recorder.Body.Bytes(), &consentResponse); err != nil { t.Fatal(err) } consentID = consentResponse.Data.ID request = authenticatedRequest(http.MethodPost, "/api/v1/subjects/"+subjectID+"/consents/"+consentID+":withdraw", ``, principal) request.SetPathValue("id", subjectID) request.SetPathValue("consent_id", consentID) recorder = httptest.NewRecorder() WithdrawConsentHandler(service).ServeHTTP(recorder, request) if recorder.Code != http.StatusOK { t.Fatalf("withdraw consent status=%d body=%s", recorder.Code, recorder.Body.String()) } } func authenticatedRequest(method, target, body string, principal identity.Principal) *http.Request { request := httptest.NewRequest(method, target, strings.NewReader(body)) request.Header.Set("Content-Type", "application/json") return request.WithContext(identity.WithPrincipal(request.Context(), principal)) }