package privacy import ( "context" "os" "testing" "data-terminal-cloud/internal/cache" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/outbox" "github.com/jackc/pgx/v5/pgxpool" ) const ( privacyDeleteSubjectID = "90000000-0000-0000-0000-000000000741" privacyDeleteSubjectPublicID = "90000000-0000-0000-0000-000000000742" privacyDeleteRequestID = "90000000-0000-0000-0000-000000000743" privacyDeleteRequestPublicID = "90000000-0000-0000-0000-000000000744" ) type privacyDeleteStore struct { removed []string } func (s *privacyDeleteStore) Remove(_ context.Context, reference string) error { s.removed = append(s.removed, reference) return nil } func TestDeleteProcessorReconcilesAllSubsystemsAndCreatesTombstone(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") redisAddress := os.Getenv("TEST_REDIS_ADDR") if databaseURL == "" || redisAddress == "" { t.Skip("TEST_DATABASE_URL and TEST_REDIS_ADDR are required") } ctx := context.Background() pool, err := database.Open(ctx, databaseURL, "privacy-delete-test") if err != nil { t.Fatal(err) } defer pool.Close() cleanupPrivacyDeleteFixture(t, ctx, pool) defer cleanupPrivacyDeleteFixture(t, context.Background(), pool) if _, err := pool.Exec(ctx, `INSERT INTO subject_profile ( id,public_id,tenant_id,household_id,preferred_name,status ) VALUES ( $1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000201','隐私删除集成长者','active' )`, privacyDeleteSubjectID, privacyDeleteSubjectPublicID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO privacy_request ( id,public_id,tenant_id,subject_id,request_type,scope,requested_by,idempotency_key,request_digest ) VALUES ( $1::uuid,$2::uuid,'10000000-0000-0000-0000-000000000001',$3::uuid, 'DELETE','{"reason_code":"subject_request"}'::jsonb, '10000000-0000-0000-0000-000000000101','privacy-delete-integration',repeat('9',64) )`, privacyDeleteRequestID, privacyDeleteRequestPublicID, privacyDeleteSubjectID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO export_task ( id,public_id,tenant_id,subject_id,requested_by,scope,status,object_ref,object_size,content_digest, expires_at,completed_at,idempotency_key,request_digest ) VALUES ( '90000000-0000-0000-0000-000000000745','90000000-0000-0000-0000-000000000746', '10000000-0000-0000-0000-000000000001',$1::uuid,'10000000-0000-0000-0000-000000000101', '{}'::jsonb,'READY','privacy-delete/export.zip',128,repeat('a',64),now()+interval '1 hour',now(), 'privacy-delete-export-integration',repeat('b',64) )`, privacyDeleteSubjectID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO privacy_subtask ( public_id,privacy_request_id,subsystem,target_type,target_ref ) SELECT gen_random_uuid(),$1::uuid,subsystem,'subject_data',subsystem FROM unnest(ARRAY['redis','export_files','object_storage','pgvector','provider_index','postgresql']) AS subsystem`, privacyDeleteRequestID); err != nil { t.Fatal(err) } redisClient := cache.Open(redisAddress, "", 0) defer redisClient.Close() store := &privacyDeleteStore{} processor, err := NewDeleteProcessor(pool, redisClient, store) if err != nil { t.Fatal(err) } if err := processor.Handle(ctx, outbox.Event{AggregateID: privacyDeleteRequestID}); err != nil { t.Fatal(err) } var requestStatus, subjectStatus, preferredName string var completed, skipped, tombstones, audits int if err := pool.QueryRow(ctx, `SELECT status FROM privacy_request WHERE id=$1::uuid`, privacyDeleteRequestID).Scan(&requestStatus); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT status,preferred_name FROM subject_profile WHERE id=$1::uuid`, privacyDeleteSubjectID).Scan(&subjectStatus, &preferredName); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FILTER(WHERE status='COMPLETED'),count(*) FILTER(WHERE status='SKIPPED') FROM privacy_subtask WHERE privacy_request_id=$1::uuid`, privacyDeleteRequestID).Scan(&completed, &skipped); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM tombstone WHERE deletion_request_id=$1::uuid`, privacyDeleteRequestID).Scan(&tombstones); err != nil { t.Fatal(err) } if err := pool.QueryRow(ctx, `SELECT count(*) FROM audit_event WHERE action='privacy.delete.complete' AND resource_id=$1::uuid`, privacyDeleteRequestPublicID).Scan(&audits); err != nil { t.Fatal(err) } var exportStatus string var exportReference *string if err := pool.QueryRow(ctx, `SELECT status,object_ref FROM export_task WHERE id='90000000-0000-0000-0000-000000000745'::uuid`).Scan(&exportStatus, &exportReference); err != nil { t.Fatal(err) } if requestStatus != "COMPLETED" || subjectStatus != "deleted" || preferredName != "[已删除]" || completed != 5 || skipped != 1 || tombstones != 1 || audits != 1 || len(store.removed) != 1 || store.removed[0] != "privacy-delete/export.zip" || exportStatus != "EXPIRED" || exportReference != nil { t.Fatalf("request=%s subject=%s name=%q completed=%d skipped=%d tombstones=%d audits=%d removed=%v", requestStatus, subjectStatus, preferredName, completed, skipped, tombstones, audits, store.removed) } if err := processor.Handle(ctx, outbox.Event{AggregateID: privacyDeleteRequestID}); err != nil { t.Fatal(err) } if err := processor.Handle(ctx, outbox.Event{AggregateID: "90000000-0000-0000-0000-000000000799"}); err != nil { t.Fatalf("missing deletion request error=%v", err) } } func cleanupPrivacyDeleteFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { t.Helper() statements := []string{ `DELETE FROM tombstone WHERE deletion_request_id='90000000-0000-0000-0000-000000000743'::uuid`, `DELETE FROM audit_event WHERE action='privacy.delete.complete' AND resource_id='90000000-0000-0000-0000-000000000744'::uuid`, `DELETE FROM privacy_request WHERE id='90000000-0000-0000-0000-000000000743'::uuid`, `DELETE FROM export_task WHERE id='90000000-0000-0000-0000-000000000745'::uuid`, `DELETE FROM subject_profile WHERE id='90000000-0000-0000-0000-000000000741'::uuid`, } for _, statement := range statements { if _, err := pool.Exec(ctx, statement); err != nil { t.Fatalf("cleanup privacy delete fixture: %v", err) } } }