package media import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "os" "testing" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/database" "data-terminal-cloud/internal/deviceauth" identitypkg "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/orchestration" "data-terminal-cloud/internal/outbox" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) const ( mediaTestConsentID = "90000000-0000-0000-0000-000000000801" mediaTestDeviceID = "10000000-0000-0000-0000-000000000501" mediaTestDevicePub = "10000000-0000-0000-0000-000000000511" ) type mediaTestStore struct { wav []byte } func (*mediaTestStore) PresignUpload(_ context.Context, reference string, _ time.Duration) (string, error) { return "https://upload.test/" + reference, nil } func (*mediaTestStore) PresignMediaDownload(_ context.Context, reference string, _ time.Duration, _, _ string, _ bool) (string, error) { return "https://download.test/" + reference, nil } func (*mediaTestStore) VerifyDigest(context.Context, string, string, int64) error { return nil } func (s *mediaTestStore) ReadPrefix(context.Context, string, int64) ([]byte, error) { return append([]byte(nil), s.wav...), nil } func TestUploadVerificationAndLocalDeleteLifecycle(t *testing.T) { databaseURL := os.Getenv("TEST_DATABASE_URL") if databaseURL == "" { t.Skip("TEST_DATABASE_URL is not set") } ctx := context.Background() pool, err := database.Open(ctx, databaseURL, "media-service-test") if err != nil { t.Fatal(err) } defer pool.Close() cleanupMediaFixture(t, ctx, pool) defer cleanupMediaFixture(t, context.Background(), pool) if _, err := pool.Exec(ctx, `INSERT INTO consent_record ( id,public_id,tenant_id,subject_id,consent_type,scope,policy_version,notice_digest,actor_user_id ) VALUES ( $1::uuid,'90000000-0000-0000-0000-000000000802','10000000-0000-0000-0000-000000000001', '10000000-0000-0000-0000-000000000401','raw_audio_storage','{}'::jsonb,'media-test-v1',repeat('d',64), '10000000-0000-0000-0000-000000000101' )`, mediaTestConsentID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `INSERT INTO consent_snapshot ( id,public_id,tenant_id,subject_id,snapshot_version,effective_consents,source_consent_ids ) VALUES ( '90000000-0000-0000-0000-000000000803','90000000-0000-0000-0000-000000000804', '10000000-0000-0000-0000-000000000001','10000000-0000-0000-0000-000000000401',9001, '{"voice_chat":true,"transcript_storage":true,"raw_audio_storage":true}'::jsonb, '["90000000-0000-0000-0000-000000000802"]'::jsonb )`); err != nil { t.Fatal(err) } identity := deviceauth.Identity{ID: mediaTestDeviceID, PublicID: mediaTestDevicePub, TenantID: "10000000-0000-0000-0000-000000000001", Status: "registered"} tx, err := pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { t.Fatal(err) } resume := true created, internalID, err := orchestration.NewRepository().Create(ctx, tx, identity, orchestration.CreateInput{ DeviceTime: time.Now().UTC(), Capabilities: orchestration.Capabilities{RTC: true, AudioTracks: []string{"near"}, UploadResume: &resume}, Network: orchestration.Network{Type: "test"}, RequestedGoal: "media_integration", }, orchestration.AllocationOptions{Now: time.Now().UTC(), AllocationExpires: time.Now().UTC().Add(time.Minute), TokenExpires: time.Now().UTC().Add(5 * time.Minute), RTCAppID: "test", EngineMode: "s2s_hybrid", HeartbeatInterval: 15}) if err != nil { _ = tx.Rollback(ctx) t.Fatal(err) } if err := tx.Commit(ctx); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE conversation SET status='COMPLETED',ended_at=now(),retention_mode='transcript_and_audio' WHERE id=$1::uuid`, internalID); err != nil { t.Fatal(err) } if _, err := pool.Exec(ctx, `UPDATE rtc_task SET status='COMPLETED',lease_expires_at=NULL WHERE conversation_id=$1::uuid`, internalID); err != nil { t.Fatal(err) } wav := syntheticWAV(16000, 1, 16000) digestValue := sha256.Sum256(wav) declaration := SegmentDeclaration{Track: "near", SequenceNo: 1, Codec: "wav_pcm_s16le", ContentType: "audio/wav", DurationMS: 1000, ExpectedSize: int64(len(wav)), ExpectedDigest: hex.EncodeToString(digestValue[:])} store := &mediaTestStore{wav: wav} service, err := NewService(pool, outbox.NewStore(pool), audit.NewRepository(), store, 15*time.Minute) if err != nil { t.Fatal(err) } batch, err := service.PrepareUploadBatch(ctx, identity, created.ConversationID, "media-integration-batch", "media-prepare", []SegmentDeclaration{declaration}) if err != nil || batch.Status != "UPLOADING" || len(batch.Segments) != 1 || batch.Segments[0].UploadURL == "" { t.Fatalf("prepared batch=%#v err=%v", batch, err) } replayed, err := service.PrepareUploadBatch(ctx, identity, created.ConversationID, "media-integration-batch", "media-prepare-replay", []SegmentDeclaration{declaration}) if err != nil || replayed.ID != batch.ID { t.Fatalf("replayed batch=%#v err=%v", replayed, err) } verifying, err := service.CompleteUploadBatch(ctx, identity, batch.ID, "media-complete") if err != nil || verifying.Status != "VERIFYING" { t.Fatalf("verifying batch=%#v err=%v", verifying, err) } processor, err := NewProcessor(pool, store, audit.NewRepository()) if err != nil { t.Fatal(err) } payload, _ := json.Marshal(map[string]string{"batch_id": batch.ID}) if err := processor.Handle(ctx, outbox.Event{Payload: payload}); err != nil { t.Fatal(err) } completed, err := service.GetUploadBatch(ctx, identity, batch.ID, false) if err != nil || completed.Status != "COMPLETED" || completed.VerifiedCount != 1 || completed.Segments[0].Status != "VERIFIED" { t.Fatalf("completed batch=%#v err=%v", completed, err) } segmentID := completed.Segments[0].ID owner := identitypkg.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000101"} editor := identitypkg.Principal{Issuer: "http://localhost:58180/realms/voice-local", Subject: "00000000-0000-0000-0000-000000000102"} grant, err := service.GrantRecordingAccess(ctx, owner, "10000000-0000-0000-0000-000000000411", "10000000-0000-0000-0000-000000000112", "PLAY", "媒体集成测试播放", "media-grant", time.Now().UTC().Add(time.Hour)) if err != nil || grant.Permission != "PLAY" { t.Fatalf("recording grant=%#v err=%v", grant, err) } grants, err := service.ListRecordingAccessGrants(ctx, owner, "10000000-0000-0000-0000-000000000411") if err != nil || len(grants) != 1 || grants[0].ID != grant.ID { t.Fatalf("recording grants=%#v err=%v", grants, err) } conversationMedia, err := service.GetConversationMedia(ctx, editor, created.ConversationID) if err != nil || !conversationMedia.CanPlay || conversationMedia.CanDownload || len(conversationMedia.Segments) != 1 { t.Fatalf("conversation media=%#v err=%v", conversationMedia, err) } access, err := service.CreateMediaAccess(ctx, editor, segmentID, "PLAY", "媒体集成测试试听", "media-access-play") if err != nil || access.Action != "PLAY" || access.AccessURL == "" { t.Fatalf("media access=%#v err=%v", access, err) } if _, err := service.CreateMediaAccess(ctx, editor, segmentID, "DOWNLOAD", "未授权下载验证", "media-access-denied"); !errors.Is(err, ErrRecordingAccessDenied) { t.Fatalf("download access error=%v", err) } revoked, err := service.RevokeRecordingAccess(ctx, owner, grant.ID, "media-revoke") if err != nil || revoked.RevokedAt == nil { t.Fatalf("revoked grant=%#v err=%v", revoked, err) } if _, err := service.GetConversationMedia(ctx, editor, created.ConversationID); !errors.Is(err, ErrRecordingAccessDenied) { t.Fatalf("revoked conversation media error=%v", err) } if _, err := service.AcknowledgeLocalDelete(ctx, identity, segmentID, "DELETED", "", "media-delete-early"); !errors.Is(err, ErrLocalDeleteTooEarly) { t.Fatalf("early local delete error=%v", err) } if _, err := pool.Exec(ctx, `UPDATE media_segment SET local_delete_after=now()-interval '1 second' WHERE public_id=$1::uuid`, segmentID); err != nil { t.Fatal(err) } deleted, err := service.AcknowledgeLocalDelete(ctx, identity, segmentID, "DELETED", "", "media-delete") if err != nil || deleted.LocalDeleteStatus != "DELETED" || deleted.LocalDeleteReportedAt == nil { t.Fatalf("deleted segment=%#v err=%v", deleted, err) } } func cleanupMediaFixture(t *testing.T, ctx context.Context, pool *pgxpool.Pool) { t.Helper() var ids []string rows, _ := pool.Query(ctx, `SELECT id::text FROM conversation WHERE device_id=$1::uuid AND EXISTS(SELECT 1 FROM context_snapshot cs WHERE cs.id=conversation.context_snapshot_id AND cs.sanitized_summary LIKE '%media_integration%')`, mediaTestDeviceID) if rows != nil { for rows.Next() { var id string _ = rows.Scan(&id) ids = append(ids, id) } rows.Close() } for _, id := range ids { for _, statement := range []string{ `DELETE FROM outbox_event WHERE aggregate_id IN (SELECT public_id FROM upload_batch WHERE conversation_id=$1::uuid)`, `DELETE FROM media_access_log WHERE conversation_id=$1::uuid`, `DELETE FROM audit_event WHERE resource_id IN (SELECT public_id FROM upload_batch WHERE conversation_id=$1::uuid UNION SELECT public_id FROM media_segment WHERE conversation_id=$1::uuid)`, `DELETE FROM upload_batch_segment WHERE upload_batch_id IN (SELECT id FROM upload_batch WHERE conversation_id=$1::uuid)`, `DELETE FROM upload_batch WHERE conversation_id=$1::uuid`, `DELETE FROM media_segment WHERE conversation_id=$1::uuid`, `DELETE FROM conversation_state_history WHERE conversation_id=$1::uuid`, `DELETE FROM rtc_task WHERE conversation_id=$1::uuid`, `DELETE FROM conversation WHERE id=$1::uuid`, } { _, _ = pool.Exec(ctx, statement, id) } } _, _ = pool.Exec(ctx, `DELETE FROM audit_event WHERE request_id LIKE 'media-%'`) _, _ = pool.Exec(ctx, `DELETE FROM recording_access_grant WHERE purpose='媒体集成测试播放'`) _, _ = pool.Exec(ctx, `DELETE FROM context_snapshot WHERE sanitized_summary LIKE '%media_integration%'`) _, _ = pool.Exec(ctx, `DELETE FROM consent_snapshot WHERE id IN ('90000000-0000-0000-0000-000000000803'::uuid) OR (subject_id='10000000-0000-0000-0000-000000000401'::uuid AND snapshot_version=9001)`) _, _ = pool.Exec(ctx, `DELETE FROM consent_record WHERE id=$1::uuid`, mediaTestConsentID) }