package saas import ( "context" "encoding/json" "errors" "fmt" "os" "strings" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/pagination" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) const ( testTenantPublicID = "10000000-0000-0000-0000-000000000010" testIssuer = "http://localhost:58180/realms/voice-local" testOwnerSubject = "00000000-0000-0000-0000-000000000101" testEditorSubject = "00000000-0000-0000-0000-000000000102" testOutsideSubject = "00000000-0000-0000-0000-000000000103" ) func TestHouseholdSubjectConsentLifecycleAndIsolation(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service := NewService(pool, audit.NewRepository(), nil, nil) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject} outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject} household, err := service.CreateHousehold(ctx, owner, testTenantPublicID, "集成测试家庭", "saas-integration-create") if err != nil { t.Fatalf("create household: %v", err) } var subjectID string defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'saas-integration-%'`) if subjectID != "" { _, _ = pool.Exec(ctx, `DELETE FROM outbox_event WHERE aggregate_id IN (SELECT id FROM privacy_request WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid) UNION SELECT id FROM export_task WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid))`, subjectID) _, _ = pool.Exec(ctx, `DELETE FROM privacy_request WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(ctx, `DELETE FROM export_task WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(ctx, `DELETE FROM consent_record WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(ctx, `DELETE FROM subject_fact WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subjectID) _, _ = pool.Exec(ctx, `DELETE FROM subject_profile WHERE public_id=$1::uuid`, subjectID) } _, _ = pool.Exec(ctx, `DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, household.ID) _, _ = pool.Exec(ctx, `DELETE FROM household WHERE public_id=$1::uuid`, household.ID) }() if household.Version != 1 || household.Role != identity.RoleOwner { t.Fatalf("unexpected household: %+v", household) } items, err := service.ListHouseholds(ctx, owner, testTenantPublicID) if err != nil || !containsHousehold(items, household.ID) { t.Fatalf("list household: items=%+v err=%v", items, err) } if _, err := service.GetHousehold(ctx, outsider, household.ID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider get error=%v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO household_member (tenant_id,household_id,user_id,role,status) SELECT h.tenant_id,h.id,ua.id,'editor','active' FROM household h JOIN user_account ua ON ua.tenant_id=h.tenant_id WHERE h.public_id=$1::uuid AND ua.oidc_issuer=$2 AND ua.oidc_subject=$3`, household.ID, testIssuer, testEditorSubject); err != nil { t.Fatalf("add editor: %v", err) } if _, err := service.PatchHousehold(ctx, editor, household.ID, "禁止修改", 1, "saas-integration-editor-denied"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("editor manage error=%v", err) } household, err = service.PatchHousehold(ctx, owner, household.ID, "集成测试家庭-已修改", 1, "saas-integration-household-patch") if err != nil || household.Version != 2 { t.Fatalf("patch household: %+v err=%v", household, err) } if _, err := service.PatchHousehold(ctx, owner, household.ID, "过期写入", 1, "saas-integration-stale"); !errors.Is(err, ErrVersionConflict) { t.Fatalf("stale household patch error=%v", err) } subject, err := service.CreateSubject(ctx, editor, household.ID, "saas-integration-subject-create", CreateSubjectInput{ PreferredName: "集成长者", Language: "zh-CN", Timezone: "Asia/Shanghai", ProfileData: map[string]any{"synthetic": true}, ConversationPreferences: map[string]any{"pace": "slow"}, }) if err != nil { t.Fatalf("create subject: %v", err) } subjectID = subject.ID subjects, err := service.ListSubjects(ctx, editor, household.ID) if err != nil || len(subjects) != 1 || subjects[0].ID != subject.ID { t.Fatalf("list subjects: %+v err=%v", subjects, err) } if _, err := service.GetSubject(ctx, outsider, subject.ID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider subject error=%v", err) } newName := "集成长者-已修改" subject, err = service.PatchSubject(ctx, editor, subject.ID, "saas-integration-subject-patch", 1, PatchSubjectInput{PreferredName: &newName}) if err != nil || subject.Version != 2 || subject.PreferredName != newName { t.Fatalf("patch subject: %+v err=%v", subject, err) } fact, err := service.CreateSubjectFact(ctx, editor, subject.ID, "saas-integration-fact-create", CreateFactInput{ FactType: "hometown", Content: "合成城市", SourceType: "model_candidate", SourceRef: "synthetic://turn/1", ConfirmationStatus: "pending", Sensitivity: "normal", }) if err != nil || fact.Version != 1 { t.Fatalf("create fact: %+v err=%v", fact, err) } confirmed := "confirmed" fact, err = service.PatchSubjectFact(ctx, editor, subject.ID, fact.ID, "saas-integration-fact-confirm", 1, PatchFactInput{ConfirmationStatus: &confirmed}) if err != nil || fact.Version != 2 || fact.ConfirmationStatus != "confirmed" { t.Fatalf("confirm fact: %+v err=%v", fact, err) } if _, err := service.PatchSubjectFact(ctx, editor, subject.ID, fact.ID, "saas-integration-fact-stale", 1, PatchFactInput{ConfirmationStatus: &confirmed}); !errors.Is(err, ErrVersionConflict) { t.Fatalf("stale fact patch error=%v", err) } facts, err := service.ListSubjectFacts(ctx, editor, subject.ID) if err != nil || len(facts) != 1 { t.Fatalf("list facts: %+v err=%v", facts, err) } grantInput := GrantConsentInput{ Type: ConsentStoryExtraction, Scope: map[string]any{"scope": "future_conversations"}, PolicyVersion: "local-v1", NoticeDigest: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", EvidenceRef: "synthetic://integration", GrantedAt: time.Now().UTC(), } consent, err := service.GrantConsent(ctx, editor, subject.ID, "saas-integration-consent-grant", grantInput) if err != nil || !consent.Effective { t.Fatalf("grant consent: %+v err=%v", consent, err) } if _, err := service.GrantConsent(ctx, editor, subject.ID, "saas-integration-consent-duplicate", grantInput); !errors.Is(err, ErrEffectiveConsentExist) { t.Fatalf("duplicate grant error=%v", err) } consent, err = service.WithdrawConsent(ctx, editor, subject.ID, consent.ID, "saas-integration-consent-withdraw") if err != nil || consent.WithdrawnAt == nil || consent.Effective { t.Fatalf("withdraw consent: %+v err=%v", consent, err) } replayed, err := service.WithdrawConsent(ctx, editor, subject.ID, consent.ID, "saas-integration-consent-withdraw-replay") if err != nil || replayed.Version != consent.Version { t.Fatalf("replay withdrawal: %+v err=%v", replayed, err) } consents, err := service.ListConsents(ctx, editor, subject.ID) if err != nil || len(consents) != 1 { t.Fatalf("list consents: %+v err=%v", consents, err) } privacyInput := CreatePrivacyRequestInput{RequestType: " access ", Scope: map[string]any{"categories": []any{"profile", "consents"}}} privacyRequest, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-privacy-key", "saas-integration-privacy-create", privacyInput) if err != nil || !created || privacyRequest.Status != "SUBMITTED" || len(privacyRequest.Subtasks) != 1 { t.Fatalf("privacy request=%+v created=%v err=%v", privacyRequest, created, err) } replayedPrivacy, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-privacy-key", "saas-integration-privacy-replay", privacyInput) if err != nil || created || replayedPrivacy.ID != privacyRequest.ID { t.Fatalf("privacy replay=%+v created=%v err=%v", replayedPrivacy, created, err) } conflictingPrivacy := privacyInput conflictingPrivacy.Scope = map[string]any{"categories": []any{"profile"}} if _, _, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-privacy-key", "saas-integration-privacy-conflict", conflictingPrivacy); !errors.Is(err, ErrIdempotencyConflict) { t.Fatalf("privacy idempotency conflict=%v", err) } correctedName := "集成长者-隐私更正" correction, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-correction-key", "saas-integration-correction", CreatePrivacyRequestInput{ RequestType: "CORRECTION", Scope: map[string]any{"corrections": map[string]any{"preferred_name": correctedName}}, }) if err != nil || !created || correction.Status != "COMPLETED" || len(correction.Subtasks) != 1 || correction.Subtasks[0].Status != "COMPLETED" { t.Fatalf("correction request=%+v created=%v err=%v", correction, created, err) } correctedSubject, err := service.GetSubject(ctx, owner, subject.ID) if err != nil || correctedSubject.PreferredName != correctedName || correctedSubject.Version != subject.Version+1 { t.Fatalf("corrected subject=%+v err=%v", correctedSubject, err) } regranted, err := service.GrantConsent(ctx, editor, subject.ID, "saas-integration-consent-regrant", grantInput) if err != nil || !regranted.Effective { t.Fatalf("regrant consent=%+v err=%v", regranted, err) } withdrawal, created, err := service.CreatePrivacyRequest(ctx, owner, subject.ID, "saas-integration-withdrawal-key", "saas-integration-withdrawal", CreatePrivacyRequestInput{ RequestType: "CONSENT_WITHDRAWAL", Scope: map[string]any{"consent_types": []string{ConsentStoryExtraction}}, }) if err != nil || !created || withdrawal.Status != "COMPLETED" || len(withdrawal.Subtasks) != 1 || withdrawal.Subtasks[0].Status != "COMPLETED" { t.Fatalf("withdrawal request=%+v created=%v err=%v", withdrawal, created, err) } consents, err = service.ListConsents(ctx, editor, subject.ID) if err != nil || len(consents) != 2 || consents[0].Effective { t.Fatalf("consents after batch withdrawal=%+v err=%v", consents, err) } if _, err := service.ListPrivacyRequests(ctx, editor, subject.ID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("editor privacy list error=%v", err) } privacyRequests, err := service.ListPrivacyRequests(ctx, owner, subject.ID) if err != nil || len(privacyRequests) != 3 { t.Fatalf("privacy requests=%+v err=%v", privacyRequests, err) } if _, err := service.GetPrivacyRequest(ctx, outsider, privacyRequest.ID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider privacy request error=%v", err) } exportScope := map[string]any{"categories": []any{"profile", "consents"}} exportTask, created, err := service.CreateExportTask(ctx, owner, subject.ID, "saas-integration-export-key", "saas-integration-export-create", exportScope) if err != nil || !created || exportTask.Status != "QUEUED" { t.Fatalf("export task=%+v created=%v err=%v", exportTask, created, err) } replayedExport, created, err := service.CreateExportTask(ctx, owner, subject.ID, "saas-integration-export-key", "saas-integration-export-replay", exportScope) if err != nil || created || replayedExport.ID != exportTask.ID { t.Fatalf("export replay=%+v created=%v err=%v", replayedExport, created, err) } if _, _, err := service.CreateExportTask(ctx, owner, subject.ID, "saas-integration-export-key", "saas-integration-export-conflict", map[string]any{"categories": []any{"profile"}}); !errors.Is(err, ErrIdempotencyConflict) { t.Fatalf("export idempotency conflict=%v", err) } if _, err := service.ListExportTasks(ctx, editor, subject.ID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("editor export list error=%v", err) } exports, err := service.ListExportTasks(ctx, owner, subject.ID) if err != nil || len(exports) != 1 || exports[0].ID != exportTask.ID { t.Fatalf("export tasks=%+v err=%v", exports, err) } if _, err := service.GetExportTask(ctx, outsider, exportTask.ID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider export task error=%v", err) } var snapshotCount int if err := pool.QueryRow(ctx, `SELECT count(*) FROM consent_snapshot WHERE subject_id=(SELECT id FROM subject_profile WHERE public_id=$1::uuid)`, subject.ID).Scan(&snapshotCount); err != nil { t.Fatal(err) } if snapshotCount != 4 { t.Fatalf("snapshot count=%d, want 4", snapshotCount) } var auditCount int if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE request_id LIKE 'saas-integration-%'`).Scan(&auditCount); err != nil { t.Fatal(err) } if auditCount != 13 { t.Fatalf("audit count=%d, want 13", auditCount) } _ = uuid.MustParse(household.ID) _ = uuid.MustParse(subject.ID) _ = uuid.MustParse(consent.ID) } func containsHousehold(items []Household, id string) bool { for _, item := range items { if item.ID == id { return true } } return false } func TestDeviceReadTwinUpdateAndIsolation(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service := NewService(pool, audit.NewRepository(), nil, nil) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject} outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject} deviceID := "10000000-0000-0000-0000-000000000511" var previousReported []byte var previousErrorCode, previousErrorSeverity *string var previousErrorAt *time.Time var previousUpdatedAt time.Time if err := pool.QueryRow(ctx, `SELECT reported_config,last_error_code,last_error_severity,last_error_at,updated_at FROM device_twin WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID).Scan(&previousReported, &previousErrorCode, &previousErrorSeverity, &previousErrorAt, &previousUpdatedAt); err != nil { t.Fatalf("read prior device report: %v", err) } failureAt := time.Now().UTC().Truncate(time.Second) if _, err := pool.Exec(ctx, `UPDATE device_twin SET reported_config=jsonb_build_object('telemetry',jsonb_build_object('occurred_at',$2::timestamptz,'status',jsonb_build_object('recording_queue',jsonb_build_object('pending_segments',3,'failed_segments',1,'last_failure_code','MEDIA_UPLOAD_TIMEOUT','last_failure_at',$2::timestamptz)))),last_error_code='MEDIA_UPLOAD_TIMEOUT',last_error_severity='error',last_error_at=$2,updated_at=$2 WHERE device_id=(SELECT id FROM device WHERE public_id=$1::uuid)`, deviceID, failureAt); err != nil { t.Fatalf("seed device upload queue: %v", err) } original, err := service.GetDeviceTwin(ctx, owner, deviceID) if err != nil { t.Fatalf("get original twin: %v", err) } originalJSON, _ := json.Marshal(original.DesiredConfig) defer func() { _, _ = pool.Exec(ctx, ` UPDATE device_twin SET desired_revision=$1,desired_config=$2::jsonb,reported_config=$4::jsonb, last_error_code=$5,last_error_severity=$6,last_error_at=$7,updated_at=$8 WHERE device_id=(SELECT id FROM device WHERE public_id=$3::uuid)`, original.DesiredRevision, originalJSON, deviceID, previousReported, previousErrorCode, previousErrorSeverity, previousErrorAt, previousUpdatedAt) _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id='saas-integration-device-twin'`) }() if original.LastErrorCode == nil || *original.LastErrorCode != "MEDIA_UPLOAD_TIMEOUT" || original.LastErrorSeverity == nil || *original.LastErrorSeverity != "error" || original.LastErrorAt == nil { t.Fatalf("device twin did not expose the latest safe error: %+v", original) } telemetry, ok := original.ReportedConfig["telemetry"].(map[string]any) if !ok || telemetry["status"] == nil { t.Fatalf("device twin did not expose the upload queue report: %+v", original.ReportedConfig) } devices, err := service.ListDevices(ctx, owner) if err != nil || !containsDevice(devices, deviceID) { t.Fatalf("list devices: %+v err=%v", devices, err) } if _, err := service.GetDevice(ctx, outsider, deviceID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider device error=%v", err) } presence, err := service.GetDevicePresence(ctx, owner, deviceID) if err != nil || presence.DeviceID != deviceID || presence.Source != "database" { t.Fatalf("database presence: %+v err=%v", presence, err) } updated, err := service.PatchDeviceTwin(ctx, editor, deviceID, "saas-integration-device-twin", original.DesiredRevision, map[string]any{ "volume": map[string]any{"speaker": 65}, "upload": map[string]any{"policy": "wifi_preferred"}, }) if err != nil || updated.DesiredRevision != original.DesiredRevision+1 || updated.SyncState != "pending" { t.Fatalf("patch device twin: %+v err=%v", updated, err) } if updated.DesiredConfig["codec"] != original.DesiredConfig["codec"] || updated.DesiredConfig["sample_rate"] != original.DesiredConfig["sample_rate"] || updated.DesiredConfig["heartbeat_interval_seconds"] != original.DesiredConfig["heartbeat_interval_seconds"] { t.Fatalf("patch device twin discarded existing device configuration: before=%+v after=%+v", original.DesiredConfig, updated.DesiredConfig) } if _, err := service.PatchDeviceTwin(ctx, editor, deviceID, "saas-integration-device-stale", original.DesiredRevision, map[string]any{}); !errors.Is(err, ErrVersionConflict) { t.Fatalf("stale twin patch error=%v", err) } } func containsDevice(items []ManagedDevice, id string) bool { for _, item := range items { if item.ID == id { return true } } return false } func TestDeviceClaimIsOneTimeAndRetrySafe(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service := NewService(pool, audit.NewRepository(), nil, nil) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject} householdID := "10000000-0000-0000-0000-000000000211" subjectID := "10000000-0000-0000-0000-000000000411" claimCode := "LOCAL-CLAIM-0001" var bindingID string defer func() { _, _ = pool.Exec(ctx, `UPDATE device_claim SET used_at=NULL,used_binding_id=NULL WHERE public_id='10000000-0000-0000-0000-000000000911'::uuid`) if bindingID != "" { _, _ = pool.Exec(ctx, `DELETE FROM device_binding WHERE public_id=$1::uuid`, bindingID) } _, _ = pool.Exec(ctx,`UPDATE device SET activation_state='CLAIMABLE' WHERE public_id='10000000-0000-0000-0000-000000000512'::uuid AND NOT EXISTS(SELECT 1 FROM device_binding WHERE device_id=device.id AND unbound_at IS NULL)`) _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id='saas-integration-device-claim'`) }() if _, _, err := service.ClaimDevice(ctx, outsider, claimCode, householdID, subjectID, "saas-integration-outsider-claim"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider claim error=%v", err) } claimed, replayed, err := service.ClaimDevice(ctx, owner, claimCode, householdID, subjectID, "saas-integration-device-claim") if err != nil || replayed { t.Fatalf("claim device: %+v replayed=%v err=%v", claimed, replayed, err) } bindingID = claimed.BindingID again, replayed, err := service.ClaimDevice(ctx, owner, claimCode, householdID, subjectID, "saas-integration-device-claim-replay") if err != nil || !replayed || again.BindingID != bindingID { t.Fatalf("replay claim: %+v replayed=%v err=%v", again, replayed, err) } var activeCount int if err := pool.QueryRow(ctx, `SELECT count(*) FROM device_binding WHERE device_id=(SELECT id FROM device WHERE public_id='10000000-0000-0000-0000-000000000512'::uuid) AND unbound_at IS NULL`).Scan(&activeCount); err != nil { t.Fatal(err) } if activeCount != 1 { t.Fatalf("active binding count=%d", activeCount) } } func TestConversationQueryPaginationHistoryAndIsolation(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() codec, err := pagination.NewCodec([]byte("integration-pagination-signing-key-32-bytes")) if err != nil { t.Fatal(err) } service := NewService(pool, audit.NewRepository(), nil, codec) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject} subjectID := "10000000-0000-0000-0000-000000000411" contextID, contextPublicID := uuid.NewString(), uuid.NewString() conversationIDs := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()} conversationPublicIDs := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()} defer func() { _, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=ANY($1::uuid[])`, conversationIDs) _, _ = pool.Exec(ctx, `DELETE FROM conversation_state_history WHERE conversation_id=ANY($1::uuid[])`, conversationIDs) _, _ = pool.Exec(ctx, `DELETE FROM rtc_task WHERE conversation_id=ANY($1::uuid[])`, conversationIDs) _, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=ANY($1::uuid[])`, conversationIDs) _, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID) }() _, err = pool.Exec(ctx, ` INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000401'::uuid,1,'10000000-0000-0000-0000-000000000701'::uuid,'[]'::jsonb,'integration-v1','s2s_hybrid',$3,'合成上下文')`, contextID, contextPublicID, "f"+strings.Repeat("0", 63)) if err != nil { t.Fatalf("insert context snapshot: %v", err) } base := time.Now().UTC().Add(-time.Hour) for index := range conversationIDs { status := "COMPLETED" failureCode := any(nil) rtcStatus := "COMPLETED" stopReason := any("user_hangup") if index == 2 { status = "FAILED" failureCode = "SYNTHETIC_FAILURE" rtcStatus = "FAILED" stopReason = nil } created := base.Add(time.Duration(index) * time.Minute) started := created.Add(time.Second) ended := started.Add(30 * time.Second) _, err = pool.Exec(ctx, ` INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id, provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason,failure_code,created_at,updated_at) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001'::uuid,'10000000-0000-0000-0000-000000000201'::uuid, '10000000-0000-0000-0000-000000000401'::uuid,'10000000-0000-0000-0000-000000000501'::uuid, '10000000-0000-0000-0000-000000000701'::uuid,$3::uuid,$4,'s2s_hybrid','transcript_only',$5,$6,$7,$8,$9,$10,$11,$11) `, conversationIDs[index], conversationPublicIDs[index], contextID, "integration-task-"+conversationIDs[index], status, created.Add(time.Minute), started, ended, stopReason, failureCode, created) if err != nil { t.Fatalf("insert conversation %d: %v", index, err) } _, err = pool.Exec(ctx, `INSERT INTO rtc_task(conversation_id,provider,provider_task_ref,room_id,device_rtc_user_id,agent_rtc_user_id,status) VALUES($1::uuid,'mock',$2,$3,$4,$5,$6)`, conversationIDs[index], "integration-task-"+conversationIDs[index], "room-"+conversationIDs[index], "device-"+conversationIDs[index], "agent-"+conversationIDs[index], rtcStatus) if err != nil { t.Fatalf("insert rtc task: %v", err) } _, err = pool.Exec(ctx, `INSERT INTO conversation_state_history(conversation_id,from_status,to_status,reason,source,occurred_at) VALUES($1::uuid,NULL,'PREPARING','integration','platform',$2),($1::uuid,'PREPARING',$3,'integration','provider',$4)`, conversationIDs[index], created, status, ended) if err != nil { t.Fatalf("insert history: %v", err) } } for sequence := 1; sequence <= 3; sequence++ { start := base.Add(time.Duration(sequence) * time.Second) if _, err := pool.Exec(ctx, `INSERT INTO conversation_turn( tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,interrupted,source_type ) VALUES ( '10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,$2,$3::bigint, CASE WHEN $3::bigint%2=1 THEN 'subject' ELSE 'agent' END,$4,$5,$6,false,'provider' )`, conversationIDs[0], fmt.Sprintf("saas-turn-%d", sequence), sequence, start, start.Add(time.Second), fmt.Sprintf("合成话轮%d", sequence)); err != nil { t.Fatalf("insert conversation turn: %v", err) } } turns, err := service.ListConversationTurns(ctx, owner, conversationPublicIDs[0], 2, "") if err != nil || len(turns.Items) != 2 || turns.NextCursor == "" { t.Fatalf("first turn page=%+v err=%v", turns, err) } remainingTurns, err := service.ListConversationTurns(ctx, owner, conversationPublicIDs[0], 2, turns.NextCursor) if err != nil || len(remainingTurns.Items) != 1 || remainingTurns.NextCursor != "" { t.Fatalf("second turn page=%+v err=%v", remainingTurns, err) } if _, err := service.ListConversationTurns(ctx, outsider, conversationPublicIDs[0], 2, ""); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider conversation turns error=%v", err) } if _, err := service.ListConversationTurns(ctx, owner, conversationPublicIDs[0], 2, turns.NextCursor+"x"); !errors.Is(err, pagination.ErrInvalidCursor) { t.Fatalf("tampered turn cursor error=%v", err) } first, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 2}) if err != nil { t.Fatalf("first page: %v", err) } if len(first.Items) != 2 || first.NextCursor == "" { t.Fatalf("first page=%+v", first) } second, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 2, Cursor: first.NextCursor}) if err != nil || len(second.Items) != 1 || second.NextCursor != "" { t.Fatalf("second page=%+v err=%v", second, err) } failed, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 20, Status: "FAILED"}) if err != nil || len(failed.Items) != 1 { t.Fatalf("failed filter=%+v err=%v", failed, err) } detail, err := service.GetConversation(ctx, owner, conversationPublicIDs[2]) if err != nil || len(detail.History) != 2 || detail.Snapshots.ContextSnapshotID != contextPublicID { t.Fatalf("detail=%+v err=%v", detail, err) } if _, err := service.GetConversation(ctx, outsider, conversationPublicIDs[2]); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider conversation error=%v", err) } if _, err := service.ListSubjectConversations(ctx, owner, subjectID, ConversationFilter{Limit: 2, Cursor: first.NextCursor + "x"}); !errors.Is(err, pagination.ErrInvalidCursor) { t.Fatalf("tampered cursor error=%v", err) } } func TestContentReviewAndMemoryLifecycle(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service := NewService(pool, audit.NewRepository(), nil, nil) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject} contextID, contextPublicID := uuid.NewString(), uuid.NewString() conversationID, conversationPublicID := uuid.NewString(), uuid.NewString() turnID, turnPublicID := uuid.NewString(), uuid.NewString() storyID, storyPublicID := uuid.NewString(), uuid.NewString() revisionID := uuid.NewString() var factPublicID string var memoryID string defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'content-integration-%'`) _, _ = pool.Exec(ctx, `DELETE FROM content_review_signal WHERE resource_public_id=$1::uuid OR resource_public_id=$2::uuid`, storyPublicID, factPublicID) if memoryID != "" { _, _ = pool.Exec(ctx, `DELETE FROM outbox_event WHERE aggregate_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID) _, _ = pool.Exec(ctx, `DELETE FROM memory_index_entry WHERE memory_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID) _, _ = pool.Exec(ctx, `UPDATE curated_memory SET current_version_id=NULL WHERE public_id=$1::uuid`, memoryID) _, _ = pool.Exec(ctx, `DELETE FROM memory_version WHERE memory_id=(SELECT id FROM curated_memory WHERE public_id=$1::uuid)`, memoryID) _, _ = pool.Exec(ctx, `DELETE FROM curated_memory WHERE public_id=$1::uuid`, memoryID) } _, _ = pool.Exec(ctx, `DELETE FROM fact_candidate_revision WHERE fact_candidate_id IN (SELECT id FROM fact_candidate WHERE story_id=$1::uuid)`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM fact_candidate WHERE story_id=$1::uuid`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM story_source WHERE story_revision_id IN (SELECT id FROM story_revision WHERE story_id=$1::uuid)`, storyID) _, _ = pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=NULL WHERE id=$1::uuid`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM story_revision WHERE story_id=$1::uuid`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM story_draft WHERE id=$1::uuid`, storyID) _, _ = pool.Exec(ctx, `DELETE FROM conversation_summary WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation_turn WHERE conversation_id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM conversation WHERE id=$1::uuid`, conversationID) _, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE id=$1::uuid`, contextID) }() if _, err := pool.Exec(ctx, ` INSERT INTO context_snapshot(id,public_id,tenant_id,subject_id,profile_version,consent_snapshot_id,memory_versions,prompt_bundle_version,engine_mode,content_digest,sanitized_summary) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',1, '10000000-0000-0000-0000-000000000701','[]','content-integration-v1','s2s_hybrid',$3,'合成内容审核上下文')`, contextID, contextPublicID, "e"+strings.Repeat("0", 63)); err != nil { t.Fatalf("insert context snapshot: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO conversation(id,public_id,tenant_id,household_id,subject_id,device_id,consent_snapshot_id,context_snapshot_id, provider_task_ref,engine_mode,retention_mode,status,allocation_expires_at,started_at,ended_at,stop_reason) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000201', '10000000-0000-0000-0000-000000000401','10000000-0000-0000-0000-000000000501', '10000000-0000-0000-0000-000000000701',$3::uuid,$4,'s2s_hybrid','transcript_only','COMPLETED',now(),now()-interval '1 minute',now(),'user_hangup')`, conversationID, conversationPublicID, contextID, "content-task-"+conversationID); err != nil { t.Fatalf("insert conversation: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO conversation_turn(id,public_id,tenant_id,subject_id,conversation_id,turn_key,sequence_no,speaker,start_at,end_at,final_text,source_type) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$3::uuid,$4,1,'subject',now()-interval '50 seconds',now()-interval '45 seconds',$5,'provider')`, turnID, turnPublicID, conversationID, "content-turn-"+turnID, "我年轻时在合成城市做过教师。"); err != nil { t.Fatalf("insert turn: %v", err) } sourceIDs, _ := json.Marshal([]string{turnPublicID}) if _, err := pool.Exec(ctx, ` INSERT INTO conversation_summary(tenant_id,subject_id,conversation_id,summary_type,content,source_turn_ids,model_ref,prompt_version,generation_version) VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,'conversation_summary',$2,$3::jsonb,'mock-content-v1','prompt-v1',1)`, conversationID, "受试者回忆了教师经历。", sourceIDs); err != nil { t.Fatalf("insert summary: %v", err) } summaries, err := service.ListConversationContentSummaries(ctx, owner, conversationPublicID) if err != nil || len(summaries) != 1 || len(summaries[0].SourceTurnIDs) != 1 || summaries[0].SourceTurnIDs[0] != turnPublicID { t.Fatalf("summaries=%+v err=%v", summaries, err) } if _, err := service.ListConversationContentSummaries(ctx, outsider, conversationPublicID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider summary error=%v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO story_draft(id,public_id,tenant_id,subject_id,conversation_id,title,status,confidence,sensitivity,generation_version) VALUES($1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$3::uuid,$4,'CANDIDATE',0.91,'normal',1)`, storyID, storyPublicID, conversationID, "教师生涯"); err != nil { t.Fatalf("insert story: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO story_revision(id,story_id,revision_no,content,author_type,model_ref,prompt_version) VALUES($1::uuid,$2::uuid,1,$3,'model','mock-content-v1','prompt-v1')`, revisionID, storyID, "受试者年轻时在合成城市从事教师工作。"); err != nil { t.Fatalf("insert story revision: %v", err) } if _, err := pool.Exec(ctx, `UPDATE story_draft SET current_revision_id=$1::uuid WHERE id=$2::uuid`, revisionID, storyID); err != nil { t.Fatalf("select current story revision: %v", err) } if _, err := pool.Exec(ctx, ` INSERT INTO story_source(story_revision_id,conversation_turn_id,source_start_offset,source_end_offset,source_role) VALUES($1::uuid,$2::uuid,0,15,'subject')`, revisionID, turnID); err != nil { t.Fatalf("insert story source: %v", err) } if err := pool.QueryRow(ctx, ` INSERT INTO fact_candidate(tenant_id,subject_id,story_id,fact_type,content,statement_basis,source_turn_ids,status,generation_version) VALUES('10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',$1::uuid,'occupation',$2,'subject_statement',$3::jsonb,'CANDIDATE',1) RETURNING public_id::text`, storyID, "曾从事教师工作", sourceIDs).Scan(&factPublicID); err != nil { t.Fatalf("insert fact candidate: %v", err) } stories, err := service.ListSubjectStories(ctx, owner, "10000000-0000-0000-0000-000000000411", "CANDIDATE") if err != nil || len(stories) != 1 || stories[0].ID != storyPublicID { t.Fatalf("stories=%+v err=%v", stories, err) } story, err := service.GetStory(ctx, owner, storyPublicID) if err != nil || len(story.Sources) != 1 || story.Sources[0].TurnID != turnPublicID || len(story.Facts) != 1 { t.Fatalf("story=%+v err=%v", story, err) } correctedFact := "曾在合成城市从事教师工作" confirmedFactStatus := "CONFIRMED" factErrorType := "PLACE_ERROR" fact, err := service.UpdateFactCandidate(ctx, owner, storyPublicID, factPublicID, "content-integration-fact-confirm", 1, UpdateFactCandidateInput{ Content: &correctedFact, Status: &confirmedFactStatus, ErrorType: &factErrorType, Reason: "逐字核对长者原话", }) if err != nil || fact.Content != correctedFact || fact.Status != "CONFIRMED" || fact.Version != 2 { t.Fatalf("confirmed fact=%+v err=%v", fact, err) } var factRevisionCount, factSignalCount int if err := pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM fact_candidate_revision WHERE fact_candidate_id=(SELECT id FROM fact_candidate WHERE public_id=$1::uuid)),(SELECT count(*) FROM content_review_signal WHERE resource_type='fact' AND resource_public_id=$1::uuid)`, factPublicID).Scan(&factRevisionCount, &factSignalCount); err != nil || factRevisionCount != 2 || factSignalCount != 1 { t.Fatalf("fact revisions=%d signals=%d err=%v", factRevisionCount, factSignalCount, err) } if _, err := service.UpdateFactCandidate(ctx, owner, storyPublicID, factPublicID, "content-integration-fact-stale", 1, UpdateFactCandidateInput{Reason: "旧页面提交"}); !errors.Is(err, ErrVersionConflict) { t.Fatalf("stale fact review error=%v", err) } if _, err := service.UpdateFactCandidate(ctx, outsider, storyPublicID, factPublicID, "content-integration-fact-outsider", 2, UpdateFactCandidateInput{Reason: "越权提交"}); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider fact review error=%v", err) } revisedContent := "受试者年轻时在合成城市任职教师,并明确这是本人陈述。" story, err = service.ReviseStory(ctx, owner, storyPublicID, revisedContent, "人工核对原话", "content-integration-revise", 1, "PLACE_ERROR") if err != nil || story.Status != "IN_REVIEW" || story.Version != 2 || story.RevisionNo != 2 || len(story.Sources) != 1 { t.Fatalf("revised story=%+v err=%v", story, err) } if _, err := service.ReviseStory(ctx, owner, storyPublicID, "过期修改", "旧版本", "content-integration-stale", 1, ""); !errors.Is(err, ErrVersionConflict) { t.Fatalf("stale story revision error=%v", err) } story, memory, err := service.ConfirmStory(ctx, owner, storyPublicID, "来源与本人同意均已核验", "content-integration-confirm", 2) if err != nil || story.Status != "CONFIRMED" || story.Version != 3 || memory.Status != "ACTIVE" || memory.Content != revisedContent || memory.IndexStatus != "PENDING" { t.Fatalf("confirmed story=%+v memory=%+v err=%v", story, memory, err) } memoryID = memory.ID if _, _, err := service.ConfirmStory(ctx, owner, storyPublicID, "重复确认", "content-integration-confirm-again", 3); !errors.Is(err, ErrReviewStateConflict) { t.Fatalf("duplicate story confirmation error=%v", err) } memories, err := service.ListSubjectMemories(ctx, owner, "10000000-0000-0000-0000-000000000411", "合成城市") if err != nil || len(memories) != 1 || memories[0].ID != memory.ID { t.Fatalf("memories=%+v err=%v", memories, err) } if _, err := service.GetMemory(ctx, outsider, memory.ID); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider memory error=%v", err) } updatedContent := revisedContent + "(家庭成员复核)" memory, err = service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-update", 1, UpdateMemoryInput{Content: &updatedContent, Reason: "补充复核结果"}) if err != nil || memory.Version != 2 || memory.VersionNo != 2 || memory.Content != updatedContent || memory.IndexStatus != "PENDING" { t.Fatalf("updated memory=%+v err=%v", memory, err) } if _, err := service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-noop", 2, UpdateMemoryInput{Content: &updatedContent, Reason: "无变化"}); !errors.Is(err, ErrReviewStateConflict) { t.Fatalf("memory no-op error=%v", err) } disabled := "DISABLED" memory, err = service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-disable", 2, UpdateMemoryInput{Status: &disabled, Reason: "暂停个性化使用"}) if err != nil || memory.Status != "DISABLED" || memory.Version != 3 || memory.IndexStatus != "STALE" { t.Fatalf("disabled memory=%+v err=%v", memory, err) } memory, err = service.RequestMemoryDeletion(ctx, owner, memory.ID, "用户要求删除", "content-integration-memory-delete", 3) if err != nil || memory.Status != "PENDING_DELETE" || memory.Version != 4 || memory.IndexStatus != "STALE" { t.Fatalf("pending deletion memory=%+v err=%v", memory, err) } active := "ACTIVE" if _, err := service.UpdateMemory(ctx, owner, memory.ID, "content-integration-memory-reactivate", 4, UpdateMemoryInput{Status: &active, Reason: "尝试恢复删除中的记忆"}); !errors.Is(err, ErrReviewStateConflict) { t.Fatalf("pending deletion memory reactivation error=%v", err) } replayed, err := service.RequestMemoryDeletion(ctx, owner, memory.ID, "重复请求", "content-integration-memory-delete-replay", 4) if err != nil || replayed.Version != memory.Version || replayed.Status != "PENDING_DELETE" { t.Fatalf("replayed memory deletion=%+v err=%v", replayed, err) } } func TestHouseholdMemberInvitationRoleRemovalAndOwnership(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := pgxpool.New(ctx, databaseURL) if err != nil { t.Fatal(err) } defer pool.Close() service := NewService(pool, audit.NewRepository(), nil, nil) owner := identity.Principal{Issuer: testIssuer, Subject: testOwnerSubject} editor := identity.Principal{Issuer: testIssuer, Subject: testEditorSubject} outsider := identity.Principal{Issuer: testIssuer, Subject: testOutsideSubject} household, err := service.CreateHousehold(ctx, owner, testTenantPublicID, "成员管理集成家庭", "member-integration-household") if err != nil { t.Fatalf("create household: %v", err) } defer func() { _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'member-integration-%'`) _, _ = pool.Exec(ctx, `DELETE FROM member_invitation WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, household.ID) _, _ = pool.Exec(ctx, `DELETE FROM household_member WHERE household_id=(SELECT id FROM household WHERE public_id=$1::uuid)`, household.ID) _, _ = pool.Exec(ctx, `DELETE FROM household WHERE public_id=$1::uuid`, household.ID) _, _ = pool.Exec(ctx, `DELETE FROM user_account WHERE tenant_id=(SELECT id FROM tenant WHERE public_id=$1::uuid) AND oidc_issuer=$2 AND oidc_subject=$3`, testTenantPublicID, testIssuer, testOutsideSubject) }() if _, err := service.CreateMemberInvitation(ctx, outsider, household.ID, "禁止", identity.RoleViewer, time.Now().Add(time.Hour), "member-integration-outsider"); !errors.Is(err, ErrAccessDenied) { t.Fatalf("outsider invitation error=%v", err) } crossTenantInvitation, err := service.CreateMemberInvitation(ctx, owner, household.ID, "跨客户组织受邀账号", identity.RoleViewer, time.Now().Add(time.Hour), "member-integration-cross-tenant-invite") if err != nil { t.Fatal(err) } crossTenantMember, _, err := service.AcceptMemberInvitation(ctx, outsider, crossTenantInvitation.Token, "member-integration-cross-tenant-accept") if err != nil || crossTenantMember.Role != identity.RoleViewer { t.Fatalf("cross-tenant account invitation: %+v err=%v", crossTenantMember, err) } invitation, err := service.CreateMemberInvitation(ctx, owner, household.ID, "editor@example.invalid", identity.RoleEditor, time.Now().Add(time.Hour), "member-integration-invite") if err != nil || invitation.Token == "" || invitation.Status != "pending" { t.Fatalf("create invitation: %+v err=%v", invitation, err) } var storedToken string if err := pool.QueryRow(ctx, `SELECT token_digest FROM member_invitation WHERE public_id=$1::uuid`, invitation.ID).Scan(&storedToken); err != nil { t.Fatal(err) } if storedToken == invitation.Token || len(storedToken) != 64 { t.Fatalf("invitation token was not stored as SHA-256 digest: %q", storedToken) } invitations, err := service.ListMemberInvitations(ctx, owner, household.ID) if err != nil || len(invitations) != 2 { t.Fatalf("member invitations=%+v err=%v", invitations, err) } member, replayed, err := service.AcceptMemberInvitation(ctx, editor, invitation.Token, "member-integration-accept") if err != nil || replayed || member.Role != identity.RoleEditor { t.Fatalf("accept invitation: %+v replayed=%v err=%v", member, replayed, err) } if _, _, err := service.AcceptMemberInvitation(ctx, editor, invitation.Token, "member-integration-reuse"); !errors.Is(err, ErrInvitationInvalid) { t.Fatalf("reused invitation error=%v", err) } var failedAcceptanceAudits int if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE request_id='member-integration-reuse' AND action='member.invitation.accept' AND result='failure'`).Scan(&failedAcceptanceAudits); err != nil || failedAcceptanceAudits != 1 { t.Fatalf("failed invitation audit count=%d err=%v", failedAcceptanceAudits, err) } members, err := service.ListHouseholdMembers(ctx, editor, household.ID) if err != nil || len(members) != 3 { t.Fatalf("list members: %+v err=%v", members, err) } var ownerMember HouseholdMember for _, item := range members { if item.IsOwner { ownerMember = item } } if _, err := service.RemoveHouseholdMember(ctx, owner, household.ID, ownerMember.ID, ownerMember.Version, "member-integration-owner-remove"); !errors.Is(err, ErrOwnerProtected) { t.Fatalf("owner removal error=%v", err) } member, err = service.PatchHouseholdMemberRole(ctx, owner, household.ID, member.ID, identity.RoleAdmin, member.Version, "member-integration-role") if err != nil || member.Role != identity.RoleAdmin || member.Version != 2 { t.Fatalf("patch member role: %+v err=%v", member, err) } if _, err := service.PatchHouseholdMemberRole(ctx, owner, household.ID, member.ID, identity.RoleViewer, 1, "member-integration-stale"); !errors.Is(err, ErrVersionConflict) { t.Fatalf("stale role patch error=%v", err) } household, err = service.TransferHouseholdOwnership(ctx, owner, household.ID, member.ID, household.Version, "member-integration-transfer") if err != nil || household.Version != 2 || household.Role != identity.RoleAdmin { t.Fatalf("transfer ownership: %+v err=%v", household, err) } members, err = service.ListHouseholdMembers(ctx, editor, household.ID) if err != nil { t.Fatal(err) } for _, item := range members { if item.UserID == ownerMember.UserID { ownerMember = item } } household, err = service.TransferHouseholdOwnership(ctx, editor, household.ID, ownerMember.ID, household.Version, "member-integration-transfer-back") if err != nil || household.Version != 3 { t.Fatalf("transfer ownership back: %+v err=%v", household, err) } members, err = service.ListHouseholdMembers(ctx, owner, household.ID) if err != nil { t.Fatal(err) } for _, item := range members { if item.UserID == member.UserID { member = item } } removed, err := service.RemoveHouseholdMember(ctx, owner, household.ID, member.ID, member.Version, "member-integration-remove") if err != nil || removed.Status != "removed" { t.Fatalf("remove member: %+v err=%v", removed, err) } revokable, err := service.CreateMemberInvitation(ctx, owner, household.ID, "待撤销", identity.RoleViewer, time.Now().Add(time.Hour), "member-integration-revokable") if err != nil { t.Fatal(err) } revoked, err := service.RevokeMemberInvitation(ctx, owner, household.ID, revokable.ID, "member-integration-revoke") if err != nil || revoked.Status != "revoked" { t.Fatalf("revoke invitation: %+v err=%v", revoked, err) } if _, _, err := service.AcceptMemberInvitation(ctx, editor, revokable.Token, "member-integration-revoked-accept"); !errors.Is(err, ErrInvitationInvalid) { t.Fatalf("revoked invitation error=%v", err) } invitations, err = service.ListMemberInvitations(ctx, owner, household.ID) if err != nil || len(invitations) != 3 { t.Fatalf("final member invitations=%+v err=%v", invitations, err) } }