417 lines
14 KiB
Go
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)
|
|
}
|
|
}
|