Files
fm_be/internal/service/file_processing_usecase_test.go
2026-07-16 22:16:45 +07:00

417 lines
14 KiB
Go

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)
}
}