Files
story/internal/httpapi/saas_write_handlers_integration_test.go
T
Codex Local bf1233d5df
ci / verify (push) Canceled after 0s
feat: expand companion platform, device controls, and memoir workflows
Add customer and operator login, BLE provisioning, device fleet controls, call memory, memoir generation, and refreshed family and maintenance portals. Include API contracts, migrations, firmware sources, tests, assets, and deployment documentation.
2026-10-08 10:06:44 +08:00

179 lines
9.1 KiB
Go

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
initialConsents, err := service.ListConsents(context.Background(), principal, subjectID)
if err != nil || len(initialConsents) != 0 {
t.Fatalf("omitted consent selection granted permissions: %+v err=%v", initialConsents, err)
}
request = authenticatedRequest(http.MethodPost, "/api/v1/households/"+householdID+"/subjects", `{"preferred_name":"未确认授权","language":"zh-CN","timezone":"Asia/Shanghai","profile_data":{},"conversation_preferences":{},"initial_consents":{"types":["personalized_memory"],"confirmed":false}}`, principal)
request.SetPathValue("id", householdID)
recorder = httptest.NewRecorder()
CreateSubjectHandler(service).ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("unconfirmed selection status=%d body=%s", recorder.Code, recorder.Body.String())
}
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))
}