2026-08-26 06:41:07 +08:00
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 )
}
2026-08-29 21:56:51 +08:00
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 )
}
2026-08-26 06:41:07 +08:00
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 )
2026-08-29 21:56:51 +08:00
if err != nil || len ( privacyRequests ) != 3 {
2026-08-26 06:41:07 +08:00
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 )
}
2026-08-29 21:56:51 +08:00
if snapshotCount != 4 {
t . Fatalf ( "snapshot count=%d, want 4" , snapshotCount )
2026-08-26 06:41:07 +08:00
}
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 )
}
2026-08-29 21:56:51 +08:00
if auditCount != 13 {
t . Fatalf ( "audit count=%d, want 13" , auditCount )
2026-08-26 06:41:07 +08:00
}
_ = 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 , `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 ()
2026-08-29 21:56:51 +08:00
var factPublicID string
2026-08-26 06:41:07 +08:00
var memoryID string
defer func () {
_ , _ = pool . Exec ( ctx , `DELETE FROM audit_event WHERE request_id LIKE 'content-integration-%'` )
2026-08-29 21:56:51 +08:00
_ , _ = pool . Exec ( ctx , `DELETE FROM content_review_signal WHERE resource_public_id=$1::uuid OR resource_public_id=$2::uuid` , storyPublicID , factPublicID )
2026-08-26 06:41:07 +08:00
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 )
}
2026-08-29 21:56:51 +08:00
_ , _ = pool . Exec ( ctx , `DELETE FROM fact_candidate_revision WHERE fact_candidate_id IN (SELECT id FROM fact_candidate WHERE story_id=$1::uuid)` , storyID )
2026-08-26 06:41:07 +08:00
_ , _ = 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 )
}
2026-08-29 21:56:51 +08:00
if err := pool . QueryRow ( ctx , `
2026-08-26 06:41:07 +08:00
INSERT INTO fact_candidate(tenant_id,subject_id,story_id,fact_type,content,statement_basis,source_turn_ids,status,generation_version)
2026-08-29 21:56:51 +08:00
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 {
2026-08-26 06:41:07 +08:00
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 )
}
2026-08-29 21:56:51 +08:00
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 )
}
2026-08-26 06:41:07 +08:00
revisedContent := "受试者年轻时在合成城市任职教师,并明确这是本人陈述。"
2026-08-29 21:56:51 +08:00
story , err = service . ReviseStory ( ctx , owner , storyPublicID , revisedContent , "人工核对原话" , "content-integration-revise" , 1 , "PLACE_ERROR" )
2026-08-26 06:41:07 +08:00
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 )
}
2026-08-29 21:56:51 +08:00
if _ , err := service . ReviseStory ( ctx , owner , storyPublicID , "过期修改" , "旧版本" , "content-integration-stale" , 1 , "" ); ! errors . Is ( err , ErrVersionConflict ) {
2026-08-26 06:41:07 +08:00
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 )
}
}