Files
story/internal/media/service_integration_test.go
T

206 lines
10 KiB
Go
Raw Normal View History

2026-08-26 06:41:07 +08:00
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)
}