package service import ( "bytes" "context" "errors" "image" "image/color" "image/jpeg" "io" "strings" "testing" "gorm.io/gorm" filemanager "wucher/internal/domain/file_manager" "wucher/internal/shared/pkg/logger" "wucher/internal/shared/pkg/uuidv7" ) type fileProcessingStorageMock struct { getObjectFn func(context.Context, string) (io.ReadCloser, filemanager.ObjectMetadata, error) putObjectFn func(context.Context, filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) deleteFn func(context.Context, string) error } func (m *fileProcessingStorageMock) GetObject(ctx context.Context, key string) (io.ReadCloser, filemanager.ObjectMetadata, error) { if m.getObjectFn != nil { return m.getObjectFn(ctx, key) } return io.NopCloser(bytes.NewReader(nil)), filemanager.ObjectMetadata{}, nil } func (m *fileProcessingStorageMock) PutObject(ctx context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) { if m.putObjectFn != nil { return m.putObjectFn(ctx, input) } return filemanager.ObjectMetadata{}, nil } func (m *fileProcessingStorageMock) DeleteObject(ctx context.Context, key string) error { if m.deleteFn != nil { return m.deleteFn(ctx, key) } return nil } func TestFileProcessingUseCase_Process_Success(t *testing.T) { fileID := uuidv7.MustBytes() fileUUID, err := uuidv7.BytesToString(fileID) if err != nil { t.Fatalf("uuid to string: %v", err) } row := &filemanager.File{ ID: append([]byte(nil), fileID...), Status: filemanager.FileStatusUploaded, Bucket: "bucket-a", ObjectKey: "files/2026/04/03/a.pdf", SizeBytes: 8, CreatedBy: uuidv7.MustBytes(), } repo := &fileManagerFileRepoMock{ getByIDAnyResult: row, transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error { if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) { return gorm.ErrRecordNotFound } row.Status = params.To row.ProcessingStartedAt = params.ProcessingStartedAt row.ProcessedAt = params.ProcessedAt row.ValidatedAt = params.ValidatedAt row.FailedAt = params.FailedAt if params.FailureReason != nil { row.FailureReason = *params.FailureReason } return nil }, } useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()}) useCase.SetFileStatusRealtimeEventWriter(repo) err = useCase.Process(context.Background(), filemanager.FileProcessingJob{ FileUUID: fileUUID, Bucket: row.Bucket, ObjectKey: row.ObjectKey, SizeBytes: row.SizeBytes, }, "corr-1") if err != nil { t.Fatalf("process: %v", err) } if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady { t.Fatalf("expected ready status, got %s", row.Status) } if repo.transitionStatusCalls != 2 { t.Fatalf("expected 2 transitions (processing+validated), got %d", repo.transitionStatusCalls) } if !repo.finalizeCalled { t.Fatalf("expected finalize to be called") } if len(repo.realtimeEvents) != 3 { t.Fatalf("expected 3 realtime events, got %d", len(repo.realtimeEvents)) } if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[0].Status); got != filemanager.FileStatusProcessing { t.Fatalf("expected first realtime status processing, got %s", got) } if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[1].Status); got != filemanager.FileStatusValidated { t.Fatalf("expected second realtime status validated, got %s", got) } if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[2].Status); got != filemanager.FileStatusReady { t.Fatalf("expected third realtime status ready, got %s", got) } } func TestFileProcessingUseCase_Process_MarkFailedOnValidationError(t *testing.T) { fileID := uuidv7.MustBytes() fileUUID, err := uuidv7.BytesToString(fileID) if err != nil { t.Fatalf("uuid to string: %v", err) } row := &filemanager.File{ ID: append([]byte(nil), fileID...), Status: filemanager.FileStatusUploaded, Bucket: "bucket-a", ObjectKey: "files/2026/04/03/a.pdf", SizeBytes: 8, CreatedBy: uuidv7.MustBytes(), } repo := &fileManagerFileRepoMock{ getByIDAnyResult: row, transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error { if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) { return gorm.ErrRecordNotFound } row.Status = params.To row.ProcessingStartedAt = params.ProcessingStartedAt row.ProcessedAt = params.ProcessedAt row.ValidatedAt = params.ValidatedAt row.FailedAt = params.FailedAt if params.FailureReason != nil { row.FailureReason = *params.FailureReason } return nil }, } useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()}) useCase.SetFileStatusRealtimeEventWriter(repo) err = useCase.Process(context.Background(), filemanager.FileProcessingJob{ FileUUID: fileUUID, Bucket: "bucket-other", ObjectKey: row.ObjectKey, SizeBytes: row.SizeBytes, }, "corr-2") if err != nil { t.Fatalf("process: %v", err) } if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusFailed { t.Fatalf("expected failed status, got %s", row.Status) } if row.FailureReason == "" { t.Fatalf("expected failure reason set") } if repo.transitionStatusCalls != 2 { t.Fatalf("expected 2 transitions (processing+failed), got %d", repo.transitionStatusCalls) } if len(repo.realtimeEvents) != 2 { t.Fatalf("expected 2 realtime events, got %d", len(repo.realtimeEvents)) } if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[0].Status); got != filemanager.FileStatusProcessing { t.Fatalf("expected first realtime status processing, got %s", got) } if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[1].Status); got != filemanager.FileStatusFailed { t.Fatalf("expected second realtime status failed, got %s", got) } if strings.TrimSpace(repo.realtimeEvents[1].FailureReason) == "" { t.Fatalf("expected failure reason on failed realtime event") } } func TestFileProcessingUseCase_Process_MarkFailedOnFinalizeError(t *testing.T) { fileID := uuidv7.MustBytes() fileUUID, err := uuidv7.BytesToString(fileID) if err != nil { t.Fatalf("uuid to string: %v", err) } row := &filemanager.File{ ID: append([]byte(nil), fileID...), Status: filemanager.FileStatusUploaded, Bucket: "bucket-a", ObjectKey: "files/2026/04/03/a.pdf", Name: "a.pdf", NameNormalized: "a.pdf", SizeBytes: 8, CreatedBy: uuidv7.MustBytes(), } repo := &fileManagerFileRepoMock{ getByIDAnyResult: row, transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error { if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) { return gorm.ErrRecordNotFound } row.Status = params.To row.ProcessingStartedAt = params.ProcessingStartedAt row.ProcessedAt = params.ProcessedAt row.ValidatedAt = params.ValidatedAt row.FailedAt = params.FailedAt if params.FailureReason != nil { row.FailureReason = *params.FailureReason } return nil }, finalizeErr: gorm.ErrDuplicatedKey, } useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()}) useCase.SetFileStatusRealtimeEventWriter(repo) err = useCase.Process(context.Background(), filemanager.FileProcessingJob{ FileUUID: fileUUID, Bucket: row.Bucket, ObjectKey: row.ObjectKey, SizeBytes: row.SizeBytes, }, "corr-5") if err != nil { t.Fatalf("process: %v", err) } if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusFailed { t.Fatalf("expected failed status, got %s", row.Status) } if repo.transitionStatusCalls != 3 { t.Fatalf("expected 3 transitions (processing+validated+failed), got %d", repo.transitionStatusCalls) } if len(repo.realtimeEvents) != 3 { t.Fatalf("expected 3 realtime events, got %d", len(repo.realtimeEvents)) } if got := filemanager.NormalizeFileStatus(repo.realtimeEvents[2].Status); got != filemanager.FileStatusFailed { t.Fatalf("expected third realtime status failed, got %s", got) } } func TestFileProcessingUseCase_Process_IgnoreNonEligibleStatus(t *testing.T) { fileID := uuidv7.MustBytes() fileUUID, err := uuidv7.BytesToString(fileID) if err != nil { t.Fatalf("uuid to string: %v", err) } row := &filemanager.File{ ID: append([]byte(nil), fileID...), Status: filemanager.FileStatusReady, Bucket: "bucket-a", ObjectKey: "files/2026/04/03/a.pdf", SizeBytes: 8, } repo := &fileManagerFileRepoMock{ getByIDAnyResult: row, } useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()}) err = useCase.Process(context.Background(), filemanager.FileProcessingJob{ FileUUID: fileUUID, Bucket: row.Bucket, ObjectKey: row.ObjectKey, SizeBytes: row.SizeBytes, }, "corr-3") if err != nil { t.Fatalf("process: %v", err) } if repo.transitionStatusCalls != 0 { t.Fatalf("expected no transition for ready status") } } func TestFileProcessingUseCase_Process_FileNotFoundPermanent(t *testing.T) { fileUUID, err := uuidv7.String(uuidv7.MustNew()) if err != nil { t.Fatalf("uuid string: %v", err) } repo := &fileManagerFileRepoMock{} useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()}) err = useCase.Process(context.Background(), filemanager.FileProcessingJob{ FileUUID: fileUUID, Bucket: "bucket-a", ObjectKey: "files/2026/04/03/a.pdf", SizeBytes: 8, }, "corr-4") if err == nil { t.Fatalf("expected not found error") } if !IsFileProcessingPermanentError(err) { t.Fatalf("expected permanent classification") } if !errors.Is(err, filemanager.ErrFileNotFound) { t.Fatalf("expected ErrFileNotFound, got %v", err) } } func TestFileProcessingUseCase_Process_CompressesJPEGInWorker(t *testing.T) { fileID := uuidv7.MustBytes() fileUUID, err := uuidv7.BytesToString(fileID) if err != nil { t.Fatalf("uuid to string: %v", err) } // Create a JPEG and ensure worker converts image payload to WebP. img := image.NewRGBA(image.Rect(0, 0, 1600, 900)) for y := 0; y < 900; y++ { for x := 0; x < 1600; x++ { img.Set(x, y, color.RGBA{R: uint8((x + y) % 255), G: uint8(x % 255), B: uint8(y % 255), A: 255}) } } var source bytes.Buffer if err := jpeg.Encode(&source, img, &jpeg.Options{Quality: 100}); err != nil { t.Fatalf("encode source jpeg: %v", err) } row := &filemanager.File{ ID: append([]byte(nil), fileID...), Status: filemanager.FileStatusUploaded, Bucket: "bucket-a", ObjectKey: "files/2026/04/03/a.jpg", SizeBytes: int64(source.Len()), MimeType: "image/jpeg", CreatedBy: uuidv7.MustBytes(), } repo := &fileManagerFileRepoMock{ getByIDAnyResult: row, transitionStatusFn: func(_ context.Context, params filemanager.FileStatusTransitionParams) error { if filemanager.NormalizeFileStatus(row.Status) != filemanager.NormalizeFileStatus(params.ExpectedFrom) { return gorm.ErrRecordNotFound } row.Status = params.To return nil }, } var putCalled bool var uploadedBytes int originalObjectKey := row.ObjectKey thumbnailObjectKey := thumbnailObjectKey(originalObjectKey, jpegContentType) storage := &fileProcessingStorageMock{ getObjectFn: func(_ context.Context, key string) (io.ReadCloser, filemanager.ObjectMetadata, error) { if key != originalObjectKey { t.Fatalf("unexpected object key %q", key) } return io.NopCloser(bytes.NewReader(source.Bytes())), filemanager.ObjectMetadata{ Bucket: row.Bucket, ObjectKey: originalObjectKey, SizeBytes: int64(source.Len()), }, nil }, putObjectFn: func(_ context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) { putCalled = true if input.ObjectKey != thumbnailObjectKey { t.Fatalf("unexpected put object key %q", input.ObjectKey) } if input.ContentType != jpegContentType { t.Fatalf("expected content-type %q, got %q", jpegContentType, input.ContentType) } body, err := io.ReadAll(input.Body) if err != nil { return filemanager.ObjectMetadata{}, err } uploadedBytes = len(body) return filemanager.ObjectMetadata{ Bucket: row.Bucket, ObjectKey: thumbnailObjectKey, ETag: "etag-new", VersionID: "v2", }, nil }, } useCase := NewFileProcessingUseCase(repo, FileProcessingUseCaseDependencies{Logger: logger.NewDiscard()}) useCase.SetObjectStorage(storage) err = useCase.Process(context.Background(), filemanager.FileProcessingJob{ FileUUID: fileUUID, Bucket: row.Bucket, ObjectKey: row.ObjectKey, SizeBytes: row.SizeBytes, }, "corr-compress") if err != nil { t.Fatalf("process: %v", err) } if !putCalled { t.Fatalf("expected compressed object upload") } if !repo.updateCalled { t.Fatalf("expected file metadata update after compression") } if uploadedBytes <= 0 { t.Fatalf("expected non-empty encoded thumbnail bytes") } if row.ThumbnailSizeBytes != int64(uploadedBytes) { t.Fatalf("expected thumbnail size updated to %d, got %d", uploadedBytes, row.ThumbnailSizeBytes) } if row.MimeType != "image/jpeg" { t.Fatalf("expected original mime type untouched, got %q", row.MimeType) } if row.ThumbnailMimeType == nil || *row.ThumbnailMimeType != jpegContentType { t.Fatalf("expected thumbnail mime type %q, got %v", jpegContentType, row.ThumbnailMimeType) } if row.ObjectKey != originalObjectKey { t.Fatalf("expected original object key untouched, got %q", row.ObjectKey) } if row.ThumbnailObjectKey == nil || *row.ThumbnailObjectKey != thumbnailObjectKey { t.Fatalf("expected thumbnail object key %q, got %v", thumbnailObjectKey, row.ThumbnailObjectKey) } if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusReady { t.Fatalf("expected ready status, got %s", row.Status) } }