//go:build unit package service import ( "context" "fmt" "testing" "time" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/stretchr/testify/require" ) // ==================== Stub: SoraGenerationRepository ==================== var _ SoraGenerationRepository = (*stubGenRepo)(nil) type stubGenRepo struct { gens map[int64]*SoraGeneration nextID int64 createErr error getErr error updateErr error deleteErr error listErr error countErr error countValue int64 } func newStubGenRepo() *stubGenRepo { return &stubGenRepo{gens: make(map[int64]*SoraGeneration), nextID: 1} } func (r *stubGenRepo) Create(_ context.Context, gen *SoraGeneration) error { if r.createErr != nil { return r.createErr } gen.ID = r.nextID gen.CreatedAt = time.Now() r.nextID++ r.gens[gen.ID] = gen return nil } func (r *stubGenRepo) GetByID(_ context.Context, id int64) (*SoraGeneration, error) { if r.getErr != nil { return nil, r.getErr } if gen, ok := r.gens[id]; ok { return gen, nil } return nil, fmt.Errorf("not found") } func (r *stubGenRepo) Update(_ context.Context, gen *SoraGeneration) error { if r.updateErr != nil { return r.updateErr } r.gens[gen.ID] = gen return nil } func (r *stubGenRepo) Delete(_ context.Context, id int64) error { if r.deleteErr != nil { return r.deleteErr } delete(r.gens, id) return nil } func (r *stubGenRepo) List(_ context.Context, params SoraGenerationListParams) ([]*SoraGeneration, int64, error) { if r.listErr != nil { return nil, 0, r.listErr } var result []*SoraGeneration for _, gen := range r.gens { if gen.UserID != params.UserID { continue } if params.Status != "" && gen.Status != params.Status { continue } if params.StorageType != "" && gen.StorageType != params.StorageType { continue } if params.MediaType != "" && gen.MediaType != params.MediaType { continue } result = append(result, gen) } return result, int64(len(result)), nil } func (r *stubGenRepo) CountByUserAndStatus(_ context.Context, userID int64, statuses []string) (int64, error) { if r.countErr != nil { return 0, r.countErr } if r.countValue > 0 { return r.countValue, nil } var count int64 statusSet := make(map[string]struct{}) for _, s := range statuses { statusSet[s] = struct{}{} } for _, gen := range r.gens { if gen.UserID == userID { if _, ok := statusSet[gen.Status]; ok { count++ } } } return count, nil } // ==================== Stub: UserRepository (用于 SoraQuotaService) ==================== var _ UserRepository = (*stubUserRepoForQuota)(nil) type stubUserRepoForQuota struct { users map[int64]*User updateErr error } func newStubUserRepoForQuota() *stubUserRepoForQuota { return &stubUserRepoForQuota{users: make(map[int64]*User)} } func (r *stubUserRepoForQuota) GetByID(_ context.Context, id int64) (*User, error) { if u, ok := r.users[id]; ok { return u, nil } return nil, fmt.Errorf("user not found") } func (r *stubUserRepoForQuota) Update(_ context.Context, user *User) error { if r.updateErr != nil { return r.updateErr } r.users[user.ID] = user return nil } func (r *stubUserRepoForQuota) Create(context.Context, *User) error { return nil } func (r *stubUserRepoForQuota) GetByEmail(context.Context, string) (*User, error) { return nil, nil } func (r *stubUserRepoForQuota) GetFirstAdmin(context.Context) (*User, error) { return nil, nil } func (r *stubUserRepoForQuota) Delete(context.Context, int64) error { return nil } func (r *stubUserRepoForQuota) List(context.Context, pagination.PaginationParams) ([]User, *pagination.PaginationResult, error) { return nil, nil, nil } func (r *stubUserRepoForQuota) ListWithFilters(context.Context, pagination.PaginationParams, UserListFilters) ([]User, *pagination.PaginationResult, error) { return nil, nil, nil } func (r *stubUserRepoForQuota) UpdateBalance(context.Context, int64, float64) error { return nil } func (r *stubUserRepoForQuota) DeductBalance(context.Context, int64, float64) error { return nil } func (r *stubUserRepoForQuota) UpdateConcurrency(context.Context, int64, int) error { return nil } func (r *stubUserRepoForQuota) ExistsByEmail(context.Context, string) (bool, error) { return false, nil } func (r *stubUserRepoForQuota) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) { return 0, nil } func (r *stubUserRepoForQuota) UpdateTotpSecret(context.Context, int64, *string) error { return nil } func (r *stubUserRepoForQuota) EnableTotp(context.Context, int64) error { return nil } func (r *stubUserRepoForQuota) DisableTotp(context.Context, int64) error { return nil } // ==================== 辅助函数:构造带 CDN 缓存的 SoraS3Storage ==================== // newS3StorageWithCDN 创建一个预缓存了 CDN 配置的 SoraS3Storage, // 避免实际初始化 AWS 客户端。用于测试 GetAccessURL 的 CDN 路径。 func newS3StorageWithCDN(cdnURL string) *SoraS3Storage { storage := &SoraS3Storage{} storage.cfg = &SoraS3Settings{ Enabled: true, Bucket: "test-bucket", CDNURL: cdnURL, } // 需要 non-nil client 使 getClient 命中缓存 storage.client = s3.New(s3.Options{}) return storage } // newS3StorageFailingDelete 创建一个 settingService=nil 的 SoraS3Storage, // 使 DeleteObjects 返回错误(无法获取配置)。用于测试 Delete 方法 S3 清理失败但仍继续的场景。 func newS3StorageFailingDelete() *SoraS3Storage { return &SoraS3Storage{} // settingService 为 nil → getConfig 返回 error } // ==================== CreatePending ==================== func TestCreatePending_Success(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) gen, err := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "一只猫跳舞", "video") require.NoError(t, err) require.Equal(t, int64(1), gen.ID) require.Equal(t, int64(1), gen.UserID) require.Equal(t, "sora2-landscape-10s", gen.Model) require.Equal(t, "一只猫跳舞", gen.Prompt) require.Equal(t, "video", gen.MediaType) require.Equal(t, SoraGenStatusPending, gen.Status) require.Equal(t, SoraStorageTypeNone, gen.StorageType) require.Nil(t, gen.APIKeyID) } func TestCreatePending_WithAPIKeyID(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) apiKeyID := int64(42) gen, err := svc.CreatePending(context.Background(), 1, &apiKeyID, "gpt-image", "画一朵花", "image") require.NoError(t, err) require.NotNil(t, gen.APIKeyID) require.Equal(t, int64(42), *gen.APIKeyID) } func TestCreatePending_RepoError(t *testing.T) { repo := newStubGenRepo() repo.createErr = fmt.Errorf("db write error") svc := NewSoraGenerationService(repo, nil, nil) gen, err := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "test", "video") require.Error(t, err) require.Nil(t, gen) require.Contains(t, err.Error(), "create generation") } // ==================== MarkGenerating ==================== func TestMarkGenerating_Success(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusPending} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkGenerating(context.Background(), 1, "upstream-task-123") require.NoError(t, err) require.Equal(t, SoraGenStatusGenerating, repo.gens[1].Status) require.Equal(t, "upstream-task-123", repo.gens[1].UpstreamTaskID) } func TestMarkGenerating_NotFound(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkGenerating(context.Background(), 999, "") require.Error(t, err) } func TestMarkGenerating_UpdateError(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusPending} repo.updateErr = fmt.Errorf("update failed") svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkGenerating(context.Background(), 1, "") require.Error(t, err) } // ==================== MarkCompleted ==================== func TestMarkCompleted_Success(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusGenerating} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCompleted(context.Background(), 1, "https://cdn.example.com/video.mp4", []string{"https://cdn.example.com/video.mp4"}, SoraStorageTypeS3, []string{"sora/1/2024/01/01/uuid.mp4"}, 1048576, ) require.NoError(t, err) gen := repo.gens[1] require.Equal(t, SoraGenStatusCompleted, gen.Status) require.Equal(t, "https://cdn.example.com/video.mp4", gen.MediaURL) require.Equal(t, []string{"https://cdn.example.com/video.mp4"}, gen.MediaURLs) require.Equal(t, SoraStorageTypeS3, gen.StorageType) require.Equal(t, []string{"sora/1/2024/01/01/uuid.mp4"}, gen.S3ObjectKeys) require.Equal(t, int64(1048576), gen.FileSizeBytes) require.NotNil(t, gen.CompletedAt) } func TestMarkCompleted_NotFound(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCompleted(context.Background(), 999, "", nil, "", nil, 0) require.Error(t, err) } func TestMarkCompleted_UpdateError(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusGenerating} repo.updateErr = fmt.Errorf("update failed") svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCompleted(context.Background(), 1, "url", nil, SoraStorageTypeUpstream, nil, 0) require.Error(t, err) } // ==================== MarkFailed ==================== func TestMarkFailed_Success(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusGenerating} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkFailed(context.Background(), 1, "上游返回 500 错误") require.NoError(t, err) gen := repo.gens[1] require.Equal(t, SoraGenStatusFailed, gen.Status) require.Equal(t, "上游返回 500 错误", gen.ErrorMessage) require.NotNil(t, gen.CompletedAt) } func TestMarkFailed_NotFound(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkFailed(context.Background(), 999, "error") require.Error(t, err) } func TestMarkFailed_UpdateError(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusGenerating} repo.updateErr = fmt.Errorf("update failed") svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkFailed(context.Background(), 1, "err") require.Error(t, err) } // ==================== MarkCancelled ==================== func TestMarkCancelled_Pending(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusPending} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCancelled(context.Background(), 1) require.NoError(t, err) require.Equal(t, SoraGenStatusCancelled, repo.gens[1].Status) require.NotNil(t, repo.gens[1].CompletedAt) } func TestMarkCancelled_Generating(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusGenerating} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCancelled(context.Background(), 1) require.NoError(t, err) require.Equal(t, SoraGenStatusCancelled, repo.gens[1].Status) } func TestMarkCancelled_Completed(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusCompleted} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCancelled(context.Background(), 1) require.Error(t, err) require.ErrorIs(t, err, ErrSoraGenerationNotActive) } func TestMarkCancelled_Failed(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusFailed} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCancelled(context.Background(), 1) require.Error(t, err) } func TestMarkCancelled_AlreadyCancelled(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusCancelled} svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCancelled(context.Background(), 1) require.Error(t, err) } func TestMarkCancelled_NotFound(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCancelled(context.Background(), 999) require.Error(t, err) } func TestMarkCancelled_UpdateError(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusPending} repo.updateErr = fmt.Errorf("update failed") svc := NewSoraGenerationService(repo, nil, nil) err := svc.MarkCancelled(context.Background(), 1) require.Error(t, err) } // ==================== GetByID ==================== func TestGetByID_Success(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusCompleted, Model: "sora2-landscape-10s"} svc := NewSoraGenerationService(repo, nil, nil) gen, err := svc.GetByID(context.Background(), 1, 1) require.NoError(t, err) require.Equal(t, int64(1), gen.ID) require.Equal(t, "sora2-landscape-10s", gen.Model) } func TestGetByID_WrongUser(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 2, Status: SoraGenStatusCompleted} svc := NewSoraGenerationService(repo, nil, nil) gen, err := svc.GetByID(context.Background(), 1, 1) require.Error(t, err) require.Nil(t, gen) require.Contains(t, err.Error(), "无权访问") } func TestGetByID_NotFound(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) gen, err := svc.GetByID(context.Background(), 999, 1) require.Error(t, err) require.Nil(t, gen) } // ==================== List ==================== func TestList_Success(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusCompleted, MediaType: "video"} repo.gens[2] = &SoraGeneration{ID: 2, UserID: 1, Status: SoraGenStatusPending, MediaType: "image"} repo.gens[3] = &SoraGeneration{ID: 3, UserID: 2, Status: SoraGenStatusCompleted, MediaType: "video"} svc := NewSoraGenerationService(repo, nil, nil) gens, total, err := svc.List(context.Background(), SoraGenerationListParams{UserID: 1, Page: 1, PageSize: 20}) require.NoError(t, err) require.Len(t, gens, 2) // 只有 userID=1 的 require.Equal(t, int64(2), total) } func TestList_DefaultPagination(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) // page=0, pageSize=0 → 应修正为 page=1, pageSize=20 _, _, err := svc.List(context.Background(), SoraGenerationListParams{UserID: 1}) require.NoError(t, err) } func TestList_MaxPageSize(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) // pageSize > 100 → 应限制为 100 _, _, err := svc.List(context.Background(), SoraGenerationListParams{UserID: 1, Page: 1, PageSize: 200}) require.NoError(t, err) } func TestList_Error(t *testing.T) { repo := newStubGenRepo() repo.listErr = fmt.Errorf("db error") svc := NewSoraGenerationService(repo, nil, nil) _, _, err := svc.List(context.Background(), SoraGenerationListParams{UserID: 1}) require.Error(t, err) } // ==================== Delete ==================== func TestDelete_Success(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusCompleted, StorageType: SoraStorageTypeUpstream} svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) _, exists := repo.gens[1] require.False(t, exists) } func TestDelete_WrongUser(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 2, Status: SoraGenStatusCompleted} svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 1, 1) require.Error(t, err) require.Contains(t, err.Error(), "无权删除") } func TestDelete_NotFound(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 999, 1) require.Error(t, err) } func TestDelete_S3Cleanup_NilS3(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{"key1"}} svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) // s3Storage 为 nil,跳过清理 } func TestDelete_QuotaRelease_NilQuota(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, StorageType: SoraStorageTypeS3, FileSizeBytes: 1024} svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) // quotaService 为 nil,跳过释放 } func TestDelete_NonS3NoCleanup(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, StorageType: SoraStorageTypeLocal, FileSizeBytes: 1024} svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) } func TestDelete_DeleteRepoError(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, StorageType: SoraStorageTypeUpstream} repo.deleteErr = fmt.Errorf("delete failed") svc := NewSoraGenerationService(repo, nil, nil) err := svc.Delete(context.Background(), 1, 1) require.Error(t, err) } // ==================== CountActiveByUser ==================== func TestCountActiveByUser_Success(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusPending} repo.gens[2] = &SoraGeneration{ID: 2, UserID: 1, Status: SoraGenStatusGenerating} repo.gens[3] = &SoraGeneration{ID: 3, UserID: 1, Status: SoraGenStatusCompleted} // 不算 svc := NewSoraGenerationService(repo, nil, nil) count, err := svc.CountActiveByUser(context.Background(), 1) require.NoError(t, err) require.Equal(t, int64(2), count) } func TestCountActiveByUser_NoActive(t *testing.T) { repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ID: 1, UserID: 1, Status: SoraGenStatusCompleted} svc := NewSoraGenerationService(repo, nil, nil) count, err := svc.CountActiveByUser(context.Background(), 1) require.NoError(t, err) require.Equal(t, int64(0), count) } func TestCountActiveByUser_Error(t *testing.T) { repo := newStubGenRepo() repo.countErr = fmt.Errorf("db error") svc := NewSoraGenerationService(repo, nil, nil) _, err := svc.CountActiveByUser(context.Background(), 1) require.Error(t, err) } // ==================== ResolveMediaURLs ==================== func TestResolveMediaURLs_NilGen(t *testing.T) { svc := NewSoraGenerationService(newStubGenRepo(), nil, nil) require.NoError(t, svc.ResolveMediaURLs(context.Background(), nil)) } func TestResolveMediaURLs_NonS3(t *testing.T) { svc := NewSoraGenerationService(newStubGenRepo(), nil, nil) gen := &SoraGeneration{StorageType: SoraStorageTypeUpstream, MediaURL: "https://original.com/v.mp4"} require.NoError(t, svc.ResolveMediaURLs(context.Background(), gen)) require.Equal(t, "https://original.com/v.mp4", gen.MediaURL) // 不变 } func TestResolveMediaURLs_S3NilStorage(t *testing.T) { svc := NewSoraGenerationService(newStubGenRepo(), nil, nil) gen := &SoraGeneration{StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{"key1"}} require.NoError(t, svc.ResolveMediaURLs(context.Background(), gen)) } func TestResolveMediaURLs_Local(t *testing.T) { svc := NewSoraGenerationService(newStubGenRepo(), nil, nil) gen := &SoraGeneration{StorageType: SoraStorageTypeLocal, MediaURL: "/video/2024/01/01/file.mp4"} require.NoError(t, svc.ResolveMediaURLs(context.Background(), gen)) require.Equal(t, "/video/2024/01/01/file.mp4", gen.MediaURL) // 不变 } // ==================== 状态流转完整测试 ==================== func TestStatusTransition_PendingToCompletedFlow(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) // 1. 创建 pending gen, err := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "test", "video") require.NoError(t, err) require.Equal(t, SoraGenStatusPending, gen.Status) // 2. 标记 generating err = svc.MarkGenerating(context.Background(), gen.ID, "task-123") require.NoError(t, err) require.Equal(t, SoraGenStatusGenerating, repo.gens[gen.ID].Status) // 3. 标记 completed err = svc.MarkCompleted(context.Background(), gen.ID, "https://s3.com/video.mp4", nil, SoraStorageTypeS3, []string{"key"}, 1024) require.NoError(t, err) require.Equal(t, SoraGenStatusCompleted, repo.gens[gen.ID].Status) } func TestStatusTransition_PendingToFailedFlow(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) gen, _ := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "test", "video") _ = svc.MarkGenerating(context.Background(), gen.ID, "") err := svc.MarkFailed(context.Background(), gen.ID, "上游超时") require.NoError(t, err) require.Equal(t, SoraGenStatusFailed, repo.gens[gen.ID].Status) require.Equal(t, "上游超时", repo.gens[gen.ID].ErrorMessage) } func TestStatusTransition_PendingToCancelledFlow(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) gen, _ := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "test", "video") err := svc.MarkCancelled(context.Background(), gen.ID) require.NoError(t, err) require.Equal(t, SoraGenStatusCancelled, repo.gens[gen.ID].Status) } func TestStatusTransition_GeneratingToCancelledFlow(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) gen, _ := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "test", "video") _ = svc.MarkGenerating(context.Background(), gen.ID, "") err := svc.MarkCancelled(context.Background(), gen.ID) require.NoError(t, err) require.Equal(t, SoraGenStatusCancelled, repo.gens[gen.ID].Status) } // ==================== 权限隔离测试 ==================== func TestUserIsolation_CannotAccessOthersRecord(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) gen, _ := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "test", "video") // 用户 2 尝试访问用户 1 的记录 _, err := svc.GetByID(context.Background(), gen.ID, 2) require.Error(t, err) require.Contains(t, err.Error(), "无权访问") } func TestUserIsolation_CannotDeleteOthersRecord(t *testing.T) { repo := newStubGenRepo() svc := NewSoraGenerationService(repo, nil, nil) gen, _ := svc.CreatePending(context.Background(), 1, nil, "sora2-landscape-10s", "test", "video") err := svc.Delete(context.Background(), gen.ID, 2) require.Error(t, err) require.Contains(t, err.Error(), "无权删除") } // ==================== Delete: S3 清理 + 配额释放路径 ==================== func TestDelete_S3Cleanup_WithS3Storage(t *testing.T) { // S3 存储存在但 deleteObjects 会失败(settingService=nil), // 验证 Delete 仍然成功(S3 错误只是记录日志) repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ ID: 1, UserID: 1, StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{"sora/1/2024/01/01/abc.mp4"}, } s3Storage := newS3StorageFailingDelete() svc := NewSoraGenerationService(repo, s3Storage, nil) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) // S3 清理失败不影响删除 _, exists := repo.gens[1] require.False(t, exists) } func TestDelete_QuotaRelease_WithQuotaService(t *testing.T) { // 有配额服务时,删除 S3 类型记录会释放配额 repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ ID: 1, UserID: 1, StorageType: SoraStorageTypeS3, FileSizeBytes: 1048576, // 1MB } userRepo := newStubUserRepoForQuota() userRepo.users[1] = &User{ID: 1, SoraStorageUsedBytes: 2097152} // 2MB quotaService := NewSoraQuotaService(userRepo, nil, nil) svc := NewSoraGenerationService(repo, nil, quotaService) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) // 配额应被释放: 2MB - 1MB = 1MB require.Equal(t, int64(1048576), userRepo.users[1].SoraStorageUsedBytes) } func TestDelete_S3Cleanup_And_QuotaRelease(t *testing.T) { // S3 清理 + 配额释放同时触发 repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ ID: 1, UserID: 1, StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{"key1"}, FileSizeBytes: 512, } userRepo := newStubUserRepoForQuota() userRepo.users[1] = &User{ID: 1, SoraStorageUsedBytes: 1024} quotaService := NewSoraQuotaService(userRepo, nil, nil) s3Storage := newS3StorageFailingDelete() svc := NewSoraGenerationService(repo, s3Storage, quotaService) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) _, exists := repo.gens[1] require.False(t, exists) require.Equal(t, int64(512), userRepo.users[1].SoraStorageUsedBytes) } func TestDelete_QuotaRelease_LocalStorage(t *testing.T) { // 本地存储同样需要释放配额 repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ ID: 1, UserID: 1, StorageType: SoraStorageTypeLocal, FileSizeBytes: 1024, } userRepo := newStubUserRepoForQuota() userRepo.users[1] = &User{ID: 1, SoraStorageUsedBytes: 2048} quotaService := NewSoraQuotaService(userRepo, nil, nil) svc := NewSoraGenerationService(repo, nil, quotaService) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) require.Equal(t, int64(1024), userRepo.users[1].SoraStorageUsedBytes) } func TestDelete_QuotaRelease_ZeroFileSize(t *testing.T) { // FileSizeBytes=0 跳过配额释放 repo := newStubGenRepo() repo.gens[1] = &SoraGeneration{ ID: 1, UserID: 1, StorageType: SoraStorageTypeS3, FileSizeBytes: 0, } userRepo := newStubUserRepoForQuota() userRepo.users[1] = &User{ID: 1, SoraStorageUsedBytes: 1024} quotaService := NewSoraQuotaService(userRepo, nil, nil) svc := NewSoraGenerationService(repo, nil, quotaService) err := svc.Delete(context.Background(), 1, 1) require.NoError(t, err) require.Equal(t, int64(1024), userRepo.users[1].SoraStorageUsedBytes) } // ==================== ResolveMediaURLs: S3 + CDN 路径 ==================== func TestResolveMediaURLs_S3_CDN_SingleKey(t *testing.T) { s3Storage := newS3StorageWithCDN("https://cdn.example.com") svc := NewSoraGenerationService(newStubGenRepo(), s3Storage, nil) gen := &SoraGeneration{ StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{"sora/1/2024/01/01/video.mp4"}, MediaURL: "original", } err := svc.ResolveMediaURLs(context.Background(), gen) require.NoError(t, err) require.Equal(t, "https://cdn.example.com/sora/1/2024/01/01/video.mp4", gen.MediaURL) } func TestResolveMediaURLs_S3_CDN_MultipleKeys(t *testing.T) { s3Storage := newS3StorageWithCDN("https://cdn.example.com/") svc := NewSoraGenerationService(newStubGenRepo(), s3Storage, nil) gen := &SoraGeneration{ StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{ "sora/1/2024/01/01/img1.png", "sora/1/2024/01/01/img2.png", "sora/1/2024/01/01/img3.png", }, MediaURL: "original", } err := svc.ResolveMediaURLs(context.Background(), gen) require.NoError(t, err) // 主 URL 更新为第一个 key 的 CDN URL require.Equal(t, "https://cdn.example.com/sora/1/2024/01/01/img1.png", gen.MediaURL) // 多图 URLs 全部更新 require.Len(t, gen.MediaURLs, 3) require.Equal(t, "https://cdn.example.com/sora/1/2024/01/01/img1.png", gen.MediaURLs[0]) require.Equal(t, "https://cdn.example.com/sora/1/2024/01/01/img2.png", gen.MediaURLs[1]) require.Equal(t, "https://cdn.example.com/sora/1/2024/01/01/img3.png", gen.MediaURLs[2]) } func TestResolveMediaURLs_S3_EmptyKeys(t *testing.T) { s3Storage := newS3StorageWithCDN("https://cdn.example.com") svc := NewSoraGenerationService(newStubGenRepo(), s3Storage, nil) gen := &SoraGeneration{ StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{}, MediaURL: "original", } err := svc.ResolveMediaURLs(context.Background(), gen) require.NoError(t, err) require.Equal(t, "original", gen.MediaURL) // 不变 } func TestResolveMediaURLs_S3_GetAccessURL_Error(t *testing.T) { // 使用无 settingService 的 S3 Storage,getClient 会失败 s3Storage := newS3StorageFailingDelete() // 同样 GetAccessURL 也会失败 svc := NewSoraGenerationService(newStubGenRepo(), s3Storage, nil) gen := &SoraGeneration{ StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{"sora/1/2024/01/01/video.mp4"}, MediaURL: "original", } err := svc.ResolveMediaURLs(context.Background(), gen) require.Error(t, err) // GetAccessURL 失败应传播错误 } func TestResolveMediaURLs_S3_MultiKey_ErrorOnSecond(t *testing.T) { // 只有一个 key 时走主 URL 路径成功,但多 key 路径的错误也需覆盖 s3Storage := newS3StorageFailingDelete() svc := NewSoraGenerationService(newStubGenRepo(), s3Storage, nil) gen := &SoraGeneration{ StorageType: SoraStorageTypeS3, S3ObjectKeys: []string{ "sora/1/2024/01/01/img1.png", "sora/1/2024/01/01/img2.png", }, MediaURL: "original", } err := svc.ResolveMediaURLs(context.Background(), gen) require.Error(t, err) // 第一个 key 的 GetAccessURL 就会失败 }