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

2621 lines
87 KiB
Go

package service
import (
"context"
"errors"
"strings"
"testing"
"time"
filemanager "wucher/internal/domain/file_manager"
"wucher/internal/queue"
"wucher/internal/shared/pkg/uuidv7"
)
type fileManagerFolderRepoMock struct {
createCalled bool
updateCalled bool
updateCalls int
moveSubtreeCalled bool
deleteCalled bool
restoreCalled bool
purgeCalled bool
getByIDCalled bool
getByIDAnyCalled bool
getByParentNameCalled bool
listByParentCalled bool
listByParentCalls int
listTrashCalled bool
listTrashByParentCalled bool
lastCreate *filemanager.Folder
lastUpdate *filemanager.Folder
updateIDs [][]byte
lastMoveSubtreeID []byte
lastMoveTargetParentID []byte
lastMoveTargetName string
lastMoveTargetNameNorm string
lastMoveUpdatedBy []byte
lastDeleteID []byte
lastDeleteBy []byte
lastDeletePurgeAt *time.Time
lastRestoreID []byte
lastRestoreBy []byte
lastPurgeID []byte
lastGetByID []byte
lastGetByIDAny []byte
lastGetParentID []byte
lastGetNameNormalized string
lastListParentID []byte
lastListSort string
lastListLimit int
lastListOffset int
lastListTrashParentID []byte
lastListTrashSortGlobal string
lastListTrashLimitGlobal int
lastListTrashOffsetGlobal int
lastListTrashSort string
lastListTrashLimit int
lastListTrashOffset int
getByIDResult *filemanager.Folder
getByIDAnyResult *filemanager.Folder
getByParentNameResult *filemanager.Folder
listByParentResult []filemanager.Folder
listByParentTotal int64
listTrashByParentResult []filemanager.Folder
listTrashResult []filemanager.Folder
listTrashByParentTotal int64
listTrashTotal int64
moveSubtreeResult *filemanager.Folder
createErr error
updateErr error
moveSubtreeErr error
deleteErr error
restoreErr error
purgeErr error
getByIDErr error
getByIDAnyErr error
getByParentNameErr error
listByParentErr error
listTrashByParentErr error
listTrashErr error
listByParentFn func(context.Context, []byte, string, int, int) ([]filemanager.Folder, int64, error)
}
func (m *fileManagerFolderRepoMock) Create(_ context.Context, row *filemanager.Folder) error {
m.createCalled = true
m.lastCreate = row
return m.createErr
}
func (m *fileManagerFolderRepoMock) Update(_ context.Context, row *filemanager.Folder) error {
m.updateCalled = true
m.updateCalls++
m.lastUpdate = row
if row != nil {
m.updateIDs = append(m.updateIDs, append([]byte(nil), row.ID...))
}
return m.updateErr
}
func (m *fileManagerFolderRepoMock) MoveSubtree(_ context.Context, id []byte, targetParentID []byte, targetName string, targetNameNormalized string, updatedBy []byte) (*filemanager.Folder, error) {
m.moveSubtreeCalled = true
m.lastMoveSubtreeID = id
m.lastMoveTargetParentID = targetParentID
m.lastMoveTargetName = targetName
m.lastMoveTargetNameNorm = targetNameNormalized
m.lastMoveUpdatedBy = updatedBy
return m.moveSubtreeResult, m.moveSubtreeErr
}
func (m *fileManagerFolderRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
m.lastDeletePurgeAt = purgeAt
return m.deleteErr
}
func (m *fileManagerFolderRepoMock) Restore(_ context.Context, id []byte, restoredBy []byte) error {
m.restoreCalled = true
m.lastRestoreID = id
m.lastRestoreBy = restoredBy
return m.restoreErr
}
func (m *fileManagerFolderRepoMock) Purge(_ context.Context, id []byte) error {
m.purgeCalled = true
m.lastPurgeID = id
return m.purgeErr
}
func (m *fileManagerFolderRepoMock) SearchByName(context.Context, string, string, int, int) ([]filemanager.Folder, int64, error) {
return nil, 0, nil
}
func (m *fileManagerFolderRepoMock) GetByID(_ context.Context, id []byte) (*filemanager.Folder, error) {
m.getByIDCalled = true
m.lastGetByID = id
return m.getByIDResult, m.getByIDErr
}
func (m *fileManagerFolderRepoMock) GetByIDAny(_ context.Context, id []byte) (*filemanager.Folder, error) {
m.getByIDAnyCalled = true
m.lastGetByIDAny = id
return m.getByIDAnyResult, m.getByIDAnyErr
}
func (m *fileManagerFolderRepoMock) GetByParentAndName(_ context.Context, parentID []byte, nameNormalized string) (*filemanager.Folder, error) {
m.getByParentNameCalled = true
m.lastGetParentID = parentID
m.lastGetNameNormalized = nameNormalized
return m.getByParentNameResult, m.getByParentNameErr
}
func (m *fileManagerFolderRepoMock) ListByParent(ctx context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
m.listByParentCalled = true
m.listByParentCalls++
m.lastListParentID = parentID
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
if m.listByParentFn != nil {
return m.listByParentFn(ctx, parentID, sort, limit, offset)
}
return m.listByParentResult, m.listByParentTotal, m.listByParentErr
}
func (m *fileManagerFolderRepoMock) ListTrashByParent(_ context.Context, parentID []byte, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
m.listTrashByParentCalled = true
m.lastListTrashParentID = parentID
m.lastListTrashSort = sort
m.lastListTrashLimit = limit
m.lastListTrashOffset = offset
return m.listTrashByParentResult, m.listTrashByParentTotal, m.listTrashByParentErr
}
func (m *fileManagerFolderRepoMock) ListTrash(_ context.Context, sort string, limit, offset int) ([]filemanager.Folder, int64, error) {
m.listTrashCalled = true
m.lastListTrashSortGlobal = sort
m.lastListTrashLimitGlobal = limit
m.lastListTrashOffsetGlobal = offset
return m.listTrashResult, m.listTrashTotal, m.listTrashErr
}
type fileManagerFileRepoMock struct {
createCalled bool
updateCalled bool
transitionStatusCalled bool
transitionStatusCalls int
withTransactionCalled bool
finalizeCalled bool
deleteCalled bool
restoreCalled bool
purgeCalled bool
getByIDCalled bool
getByIDAnyCalled bool
getByObjectKeyCalled bool
getByFolderNameCalled bool
getByTemplateTakeoverCalled bool
listByFolderCalled bool
listTrashByFolderCalled bool
updateLifecycleCalled bool
isFileReferencedCalled bool
listByLifecycleCalled bool
lastCreate *filemanager.File
lastUpdate *filemanager.File
lastTransitionStatus filemanager.FileStatusTransitionParams
lastFinalize filemanager.FileFinalizeParams
lastDeleteID []byte
lastDeleteBy []byte
lastDeletePurgeAt *time.Time
lastRestoreID []byte
lastRestoreBy []byte
lastPurgeID []byte
lastGetByID []byte
lastGetByIDAny []byte
lastGetByObjectKey string
lastGetFolderID []byte
lastGetNameNormalized string
lastListFolderID []byte
lastListSort string
lastListLimit int
lastListOffset int
lastTrashListFolderID []byte
lastTrashListSort string
lastTrashListLimit int
lastTrashListOffset int
getByIDResult *filemanager.File
getByIDAnyResult *filemanager.File
getByObjectKeyResult *filemanager.File
getByFolderNameResult *filemanager.File
listByFolderResult []filemanager.File
listByFolderTotal int64
listTrashByFolderResult []filemanager.File
listTrashByFolderTotal int64
listByLifecycleResult []filemanager.File
outboxMessages []*filemanager.FileProcessingOutboxMessage
realtimeEvents []filemanager.FileStatusRealtimeEventInput
createErr error
updateErr error
transitionStatusErr error
withTransactionErr error
outboxErr error
realtimeEventErr error
finalizeErr error
deleteErr error
restoreErr error
purgeErr error
getByIDErr error
getByIDAnyErr error
getByObjectKeyErr error
getByFolderNameErr error
getByTemplateTakeoverErr error
listByFolderErr error
listTrashByFolderErr error
updateLifecycleErr error
isFileReferencedErr error
listByLifecycleErr error
isFileReferencedResult bool
createFn func(context.Context, *filemanager.File) error
updateFn func(context.Context, *filemanager.File) error
transitionStatusFn func(context.Context, filemanager.FileStatusTransitionParams) error
finalizeFn func(context.Context, filemanager.FileFinalizeParams) error
getByIDFn func(context.Context, []byte) (*filemanager.File, error)
getByFolderNameFn func(context.Context, []byte, string) (*filemanager.File, error)
getByTemplateTakeoverFn func(context.Context, []byte, []byte) (*filemanager.File, error)
listByLifecycleFn func(context.Context, filemanager.FileLifecycleListFilter) ([]filemanager.File, error)
isReferencedFn func(context.Context, []byte) (bool, error)
uploadIntentByID map[string]*filemanager.FileUploadIntent
}
func (m *fileManagerFileRepoMock) WithTransaction(ctx context.Context, fn func(repo filemanager.FileRepository, outbox filemanager.FileProcessingOutboxWriter) error) error {
m.withTransactionCalled = true
if m.withTransactionErr != nil {
return m.withTransactionErr
}
if fn == nil {
return nil
}
return fn(m, m)
}
func (m *fileManagerFileRepoMock) CreateFileProcessingOutboxMessage(_ context.Context, message *filemanager.FileProcessingOutboxMessage) error {
if m.outboxErr != nil {
return m.outboxErr
}
if message == nil {
return errors.New("nil outbox message")
}
cp := *message
cp.ID = append([]byte(nil), message.ID...)
cp.Body = append([]byte(nil), message.Body...)
cp.Attributes = append([]byte(nil), message.Attributes...)
m.outboxMessages = append(m.outboxMessages, &cp)
return nil
}
func (m *fileManagerFileRepoMock) CreateFileStatusRealtimeEvent(_ context.Context, input filemanager.FileStatusRealtimeEventInput) error {
if m.realtimeEventErr != nil {
return m.realtimeEventErr
}
cp := input
cp.FileID = append([]byte(nil), input.FileID...)
cp.UserID = append([]byte(nil), input.UserID...)
m.realtimeEvents = append(m.realtimeEvents, cp)
return nil
}
func (m *fileManagerFileRepoMock) Create(ctx context.Context, row *filemanager.File) error {
m.createCalled = true
if row != nil && len(row.ID) == 0 {
row.ID = uuidv7.MustBytes()
}
m.lastCreate = row
if m.createFn != nil {
return m.createFn(ctx, row)
}
return m.createErr
}
func (m *fileManagerFileRepoMock) Update(ctx context.Context, row *filemanager.File) error {
m.updateCalled = true
m.lastUpdate = row
if m.updateFn != nil {
return m.updateFn(ctx, row)
}
return m.updateErr
}
func (m *fileManagerFileRepoMock) TransitionStatus(ctx context.Context, params filemanager.FileStatusTransitionParams) error {
m.transitionStatusCalled = true
m.transitionStatusCalls++
m.lastTransitionStatus = params
if m.transitionStatusFn != nil {
return m.transitionStatusFn(ctx, params)
}
return m.transitionStatusErr
}
func (m *fileManagerFileRepoMock) Finalize(ctx context.Context, params filemanager.FileFinalizeParams) error {
m.finalizeCalled = true
m.lastFinalize = params
if m.finalizeFn != nil {
return m.finalizeFn(ctx, params)
}
return m.finalizeErr
}
func (m *fileManagerFileRepoMock) Delete(_ context.Context, id []byte, deletedBy []byte, purgeAt *time.Time) error {
m.deleteCalled = true
m.lastDeleteID = id
m.lastDeleteBy = deletedBy
m.lastDeletePurgeAt = purgeAt
return m.deleteErr
}
func (m *fileManagerFileRepoMock) Restore(_ context.Context, id []byte, restoredBy []byte) error {
m.restoreCalled = true
m.lastRestoreID = id
m.lastRestoreBy = restoredBy
return m.restoreErr
}
func (m *fileManagerFileRepoMock) Purge(_ context.Context, id []byte) error {
m.purgeCalled = true
m.lastPurgeID = id
return m.purgeErr
}
func (m *fileManagerFileRepoMock) SearchByName(context.Context, string, string, int, int) ([]filemanager.File, int64, error) {
return nil, 0, nil
}
func (m *fileManagerFileRepoMock) GetByID(ctx context.Context, id []byte) (*filemanager.File, error) {
m.getByIDCalled = true
m.lastGetByID = id
if m.getByIDFn != nil {
return m.getByIDFn(ctx, id)
}
return m.getByIDResult, m.getByIDErr
}
func (m *fileManagerFileRepoMock) GetByIDAny(_ context.Context, id []byte) (*filemanager.File, error) {
m.getByIDAnyCalled = true
m.lastGetByIDAny = id
return m.getByIDAnyResult, m.getByIDAnyErr
}
func (m *fileManagerFileRepoMock) GetByObjectKey(_ context.Context, objectKey string) (*filemanager.File, error) {
m.getByObjectKeyCalled = true
m.lastGetByObjectKey = objectKey
return m.getByObjectKeyResult, m.getByObjectKeyErr
}
func (m *fileManagerFileRepoMock) GetByFolderAndName(ctx context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
m.getByFolderNameCalled = true
m.lastGetFolderID = folderID
m.lastGetNameNormalized = nameNormalized
if m.getByFolderNameFn != nil {
return m.getByFolderNameFn(ctx, folderID, nameNormalized)
}
return m.getByFolderNameResult, m.getByFolderNameErr
}
func (m *fileManagerFileRepoMock) GetByTemplateAndTakeover(ctx context.Context, templateUUID, takeoverID []byte) (*filemanager.File, error) {
m.getByTemplateTakeoverCalled = true
if m.getByTemplateTakeoverFn != nil {
return m.getByTemplateTakeoverFn(ctx, templateUUID, takeoverID)
}
return nil, m.getByTemplateTakeoverErr
}
func (m *fileManagerFileRepoMock) ListByFolder(_ context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
m.listByFolderCalled = true
m.lastListFolderID = folderID
m.lastListSort = sort
m.lastListLimit = limit
m.lastListOffset = offset
return m.listByFolderResult, m.listByFolderTotal, m.listByFolderErr
}
func (m *fileManagerFileRepoMock) ListTrashByFolder(_ context.Context, folderID []byte, sort string, limit, offset int) ([]filemanager.File, int64, error) {
m.listTrashByFolderCalled = true
m.lastTrashListFolderID = folderID
m.lastTrashListSort = sort
m.lastTrashListLimit = limit
m.lastTrashListOffset = offset
return m.listTrashByFolderResult, m.listTrashByFolderTotal, m.listTrashByFolderErr
}
func (m *fileManagerFileRepoMock) UpdateLifecycle(_ context.Context, params filemanager.FileLifecycleTransitionParams) error {
m.updateLifecycleCalled = true
if m.updateLifecycleErr != nil {
return m.updateLifecycleErr
}
if m.getByIDAnyResult != nil && string(m.getByIDAnyResult.ID) == string(params.ID) {
m.getByIDAnyResult.LifecycleStatus = params.Status
m.getByIDAnyResult.AttachedAt = params.AttachedAt
if params.ClearOrphanedAt {
m.getByIDAnyResult.OrphanedAt = nil
} else {
m.getByIDAnyResult.OrphanedAt = params.OrphanedAt
}
m.getByIDAnyResult.LifecycleDeletedAt = params.LifecycleDelete
}
return nil
}
func (m *fileManagerFileRepoMock) IsFileReferenced(ctx context.Context, fileID []byte) (bool, error) {
m.isFileReferencedCalled = true
if m.isReferencedFn != nil {
return m.isReferencedFn(ctx, fileID)
}
if m.isFileReferencedErr != nil {
return false, m.isFileReferencedErr
}
return m.isFileReferencedResult, nil
}
func (m *fileManagerFileRepoMock) ListFilesByLifecycle(ctx context.Context, filter filemanager.FileLifecycleListFilter) ([]filemanager.File, error) {
m.listByLifecycleCalled = true
if m.listByLifecycleFn != nil {
return m.listByLifecycleFn(ctx, filter)
}
if m.listByLifecycleErr != nil {
return nil, m.listByLifecycleErr
}
return m.listByLifecycleResult, nil
}
func (m *fileManagerFileRepoMock) CreateUploadIntent(_ context.Context, row *filemanager.FileUploadIntent) error {
if row == nil {
return errors.New("upload intent is required")
}
cp := *row
cp.ID = append([]byte(nil), row.ID...)
cp.CreatedBy = append([]byte(nil), row.CreatedBy...)
cp.UpdatedBy = append([]byte(nil), row.UpdatedBy...)
if m.uploadIntentByID == nil {
m.uploadIntentByID = map[string]*filemanager.FileUploadIntent{}
}
m.uploadIntentByID[string(cp.ID)] = &cp
return nil
}
func (m *fileManagerFileRepoMock) GetUploadIntentByID(_ context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
if m.uploadIntentByID == nil {
return nil, nil
}
row, ok := m.uploadIntentByID[string(id)]
if !ok || row == nil {
return nil, nil
}
cp := *row
cp.ID = append([]byte(nil), row.ID...)
cp.CreatedBy = append([]byte(nil), row.CreatedBy...)
cp.UpdatedBy = append([]byte(nil), row.UpdatedBy...)
return &cp, nil
}
func (m *fileManagerFileRepoMock) GetUploadIntentByIDForUpdate(ctx context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
return m.GetUploadIntentByID(ctx, id)
}
func (m *fileManagerFileRepoMock) MarkUploadIntentCompleted(_ context.Context, params filemanager.FileUploadIntentCompleteParams) error {
if m.uploadIntentByID == nil {
return nil
}
row, ok := m.uploadIntentByID[string(params.ID)]
if !ok || row == nil {
return nil
}
completedAt := params.CompletedAt
row.Status = filemanager.FileUploadIntentStatusCompleted
row.CompletedAt = &completedAt
row.UpdatedBy = append([]byte(nil), params.UpdatedBy...)
return nil
}
func (m *fileManagerFileRepoMock) MarkUploadIntentExpired(_ context.Context, params filemanager.FileUploadIntentExpireParams) error {
if m.uploadIntentByID == nil {
return nil
}
row, ok := m.uploadIntentByID[string(params.ID)]
if !ok || row == nil {
return nil
}
row.Status = filemanager.FileUploadIntentStatusExpired
row.UpdatedBy = append([]byte(nil), params.UpdatedBy...)
return nil
}
type fileManagerStorageMock struct {
putCalls int
deleteCalls int
deletedKeys []string
presignPutCalls int
putErrAt map[int]error
lastInput filemanager.PutObjectInput
headMeta filemanager.ObjectMetadata
headErr error
presignPut filemanager.PresignedPutObject
presignErr error
taggingCalls int
lastTags map[string]string
taggingErr error
}
type fileProcessingQueueMock struct {
enqueueCalled bool
lastJob filemanager.FileProcessingJob
enqueueErr error
}
type fileUploadIntentRepoMock struct {
createCalled bool
getByIDCalled bool
getByIDForUpdateCalled bool
markCompletedCalled bool
markExpiredCalled bool
lastCreate *filemanager.FileUploadIntent
lastGetID []byte
lastGetForUpdateID []byte
lastMarkCompletedParams filemanager.FileUploadIntentCompleteParams
lastMarkExpiredParams filemanager.FileUploadIntentExpireParams
createErr error
getByIDErr error
getByIDForUpdateErr error
markCompletedErr error
markExpiredErr error
getByIDResult *filemanager.FileUploadIntent
getByIDForUpdateResult *filemanager.FileUploadIntent
storedByID map[string]*filemanager.FileUploadIntent
}
func (m *fileUploadIntentRepoMock) CreateUploadIntent(_ context.Context, row *filemanager.FileUploadIntent) error {
m.createCalled = true
if row != nil {
cp := *row
cp.ID = append([]byte(nil), row.ID...)
cp.CreatedBy = append([]byte(nil), row.CreatedBy...)
cp.UpdatedBy = append([]byte(nil), row.UpdatedBy...)
m.lastCreate = &cp
if m.storedByID == nil {
m.storedByID = map[string]*filemanager.FileUploadIntent{}
}
m.storedByID[string(cp.ID)] = &cp
}
return m.createErr
}
func (m *fileUploadIntentRepoMock) GetUploadIntentByID(_ context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
m.getByIDCalled = true
m.lastGetID = append([]byte(nil), id...)
if m.getByIDErr != nil {
return nil, m.getByIDErr
}
if m.getByIDResult != nil {
cp := *m.getByIDResult
cp.ID = append([]byte(nil), m.getByIDResult.ID...)
return &cp, nil
}
if m.storedByID != nil {
if row, ok := m.storedByID[string(id)]; ok {
cp := *row
cp.ID = append([]byte(nil), row.ID...)
return &cp, nil
}
}
return nil, nil
}
func (m *fileUploadIntentRepoMock) GetUploadIntentByIDForUpdate(_ context.Context, id []byte) (*filemanager.FileUploadIntent, error) {
m.getByIDForUpdateCalled = true
m.lastGetForUpdateID = append([]byte(nil), id...)
if m.getByIDForUpdateErr != nil {
return nil, m.getByIDForUpdateErr
}
if m.getByIDForUpdateResult != nil {
cp := *m.getByIDForUpdateResult
cp.ID = append([]byte(nil), m.getByIDForUpdateResult.ID...)
return &cp, nil
}
if m.storedByID != nil {
if row, ok := m.storedByID[string(id)]; ok {
cp := *row
cp.ID = append([]byte(nil), row.ID...)
return &cp, nil
}
}
return nil, nil
}
func (m *fileUploadIntentRepoMock) MarkUploadIntentCompleted(_ context.Context, params filemanager.FileUploadIntentCompleteParams) error {
m.markCompletedCalled = true
m.lastMarkCompletedParams = params
if m.markCompletedErr != nil {
return m.markCompletedErr
}
if m.storedByID != nil {
if row, ok := m.storedByID[string(params.ID)]; ok {
row.Status = filemanager.FileUploadIntentStatusCompleted
completedAt := params.CompletedAt
row.CompletedAt = &completedAt
}
}
return nil
}
func (m *fileUploadIntentRepoMock) MarkUploadIntentExpired(_ context.Context, params filemanager.FileUploadIntentExpireParams) error {
m.markExpiredCalled = true
m.lastMarkExpiredParams = params
if m.markExpiredErr != nil {
return m.markExpiredErr
}
if m.storedByID != nil {
if row, ok := m.storedByID[string(params.ID)]; ok {
row.Status = filemanager.FileUploadIntentStatusExpired
}
}
return nil
}
func (m *fileProcessingQueueMock) Enqueue(_ context.Context, job filemanager.FileProcessingJob) error {
m.enqueueCalled = true
m.lastJob = job
return m.enqueueErr
}
func (m *fileManagerStorageMock) EnsureBucket(_ context.Context) error { return nil }
func (m *fileManagerStorageMock) PutObject(_ context.Context, input filemanager.PutObjectInput) (filemanager.ObjectMetadata, error) {
m.putCalls++
m.lastInput = input
if m.putErrAt != nil {
if err, ok := m.putErrAt[m.putCalls]; ok && err != nil {
return filemanager.ObjectMetadata{}, err
}
}
return filemanager.ObjectMetadata{
Bucket: "test-bucket",
ObjectKey: input.ObjectKey,
ETag: "etag",
VersionID: "v1",
SizeBytes: input.SizeBytes,
}, nil
}
func (m *fileManagerStorageMock) DeleteObject(_ context.Context, key string) error {
m.deleteCalls++
m.deletedKeys = append(m.deletedKeys, key)
return nil
}
func (m *fileManagerStorageMock) HeadObject(_ context.Context, objectKey string) (filemanager.ObjectMetadata, error) {
if m.headErr != nil {
return filemanager.ObjectMetadata{}, m.headErr
}
if strings.TrimSpace(m.headMeta.ObjectKey) != "" {
return m.headMeta, nil
}
return filemanager.ObjectMetadata{
Bucket: "test-bucket",
ObjectKey: objectKey,
SizeBytes: m.lastInput.SizeBytes,
}, nil
}
func (m *fileManagerStorageMock) PresignPutObject(_ context.Context, input filemanager.PresignPutObjectInput) (filemanager.PresignedPutObject, error) {
m.presignPutCalls++
if m.presignErr != nil {
return filemanager.PresignedPutObject{}, m.presignErr
}
if strings.TrimSpace(m.presignPut.URL) != "" {
return m.presignPut, nil
}
return filemanager.PresignedPutObject{
Bucket: "test-bucket",
ObjectKey: input.ObjectKey,
Method: "PUT",
URL: "https://example.local/upload",
Headers: map[string]string{"content-type": input.ContentType},
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
}, nil
}
func (m *fileManagerStorageMock) PresignGetObject(_ context.Context, _ string, _ time.Duration) (string, error) {
return "https://example.local/presigned", nil
}
func (m *fileManagerStorageMock) PutObjectTagging(_ context.Context, _ string, tags map[string]string) error {
m.taggingCalls++
m.lastTags = tags
return m.taggingErr
}
func (m *fileManagerStorageMock) DeleteObjectTaggingKeys(_ context.Context, _ string, _ []string) error {
return nil
}
func TestNewFileManagerService(t *testing.T) {
folderRepo := &fileManagerFolderRepoMock{}
fileRepo := &fileManagerFileRepoMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
if svc == nil || svc.folderRepo == nil || svc.fileRepo == nil {
t.Fatalf("expected service initialized")
}
}
func TestFileManagerServiceFolderMethods(t *testing.T) {
folderRepo := &fileManagerFolderRepoMock{
getByIDResult: &filemanager.Folder{Name: "Folder A"},
getByIDAnyResult: &filemanager.Folder{Name: "Folder Any"},
getByParentNameResult: &filemanager.Folder{Name: "Folder B"},
listByParentResult: []filemanager.Folder{{Name: "Folder A"}},
listByParentTotal: 1,
listTrashByParentResult: []filemanager.Folder{{Name: "Folder Trash"}},
listTrashByParentTotal: 1,
}
fileRepo := &fileManagerFileRepoMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
row := &filemanager.Folder{Name: "Create"}
if err := svc.CreateFolder(context.Background(), row); err != nil {
t.Fatalf("create folder: %v", err)
}
if !folderRepo.createCalled || folderRepo.lastCreate != row {
t.Fatalf("expected CreateFolder forwarded")
}
if err := svc.UpdateFolder(context.Background(), row); err != nil {
t.Fatalf("update folder: %v", err)
}
if !folderRepo.updateCalled || folderRepo.lastUpdate != row {
t.Fatalf("expected UpdateFolder forwarded")
}
id := []byte("folder-id")
actor := []byte("actor-id")
purgeAt := time.Now().UTC().Add(24 * time.Hour)
folderRepo.listByParentTotal = 0
fileRepo.listByFolderTotal = 0
if err := svc.DeleteFolder(context.Background(), id, actor, &purgeAt); err != nil {
t.Fatalf("delete folder: %v", err)
}
if !folderRepo.deleteCalled {
t.Fatalf("expected DeleteFolder forwarded")
}
deletedAt := time.Now().UTC()
folderRepo.getByIDAnyResult = &filemanager.Folder{
ID: id,
Name: "Folder Any",
PathCache: "/Folder Any",
DeletedAt: &deletedAt,
}
folderRepo.getByParentNameResult = nil
folderRepo.updateCalled = false
if err := svc.RestoreFolder(context.Background(), id, nil, false, actor); err != nil {
t.Fatalf("restore folder: %v", err)
}
if !folderRepo.updateCalled {
t.Fatalf("expected RestoreFolder to update folder state")
}
folderRepo.listByParentTotal = 1
fileRepo.listByFolderTotal = 1
if err := svc.PurgeFolder(context.Background(), id); err != nil {
t.Fatalf("purge folder: %v", err)
}
if !folderRepo.purgeCalled {
t.Fatalf("expected PurgeFolder forwarded")
}
gotByID, err := svc.GetFolderByID(context.Background(), id)
if err != nil {
t.Fatalf("get folder by id: %v", err)
}
if gotByID == nil || gotByID.Name != "Folder A" {
t.Fatalf("unexpected folder by id result")
}
gotByIDAny, err := svc.GetFolderByIDAny(context.Background(), id)
if err != nil {
t.Fatalf("get folder by id any: %v", err)
}
if gotByIDAny == nil || gotByIDAny.Name != "Folder Any" {
t.Fatalf("unexpected folder by id any result")
}
folderRepo.getByParentNameResult = &filemanager.Folder{Name: "Folder B"}
gotByName, err := svc.GetFolderByParentAndName(context.Background(), nil, "folder-b")
if err != nil {
t.Fatalf("get folder by parent/name: %v", err)
}
if gotByName == nil || gotByName.Name != "Folder B" {
t.Fatalf("unexpected folder by parent/name result")
}
rows, total, err := svc.ListFoldersByParent(context.Background(), nil, "-name", 10, 0)
if err != nil {
t.Fatalf("list folders: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("unexpected list folders result")
}
if folderRepo.lastListSort != "name DESC" {
t.Fatalf("expected normalized folder sort, got %q", folderRepo.lastListSort)
}
trashRows, trashTotal, err := svc.ListTrashFoldersByParent(context.Background(), nil, "name", 10, 0)
if err != nil {
t.Fatalf("list trash folders: %v", err)
}
if trashTotal != 1 || len(trashRows) != 1 {
t.Fatalf("unexpected list trash folders result")
}
if folderRepo.lastListTrashSort != "name ASC" {
t.Fatalf("expected normalized trash folder sort, got %q", folderRepo.lastListTrashSort)
}
folderRepo.createErr = errors.New("create failed")
if err := svc.CreateFolder(context.Background(), row); err == nil {
t.Fatalf("expected create error")
}
}
func TestFileManagerServiceFileMethods(t *testing.T) {
folderID := []byte("folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByIDAnyResult: &filemanager.Folder{ID: append([]byte(nil), folderID...)},
}
fileRepo := &fileManagerFileRepoMock{
getByIDResult: &filemanager.File{Name: "File A"},
getByIDAnyResult: &filemanager.File{Name: "File Any", Status: filemanager.FileStatusReady},
getByObjectKeyResult: &filemanager.File{Name: "File B"},
getByFolderNameResult: &filemanager.File{Name: "File C"},
listByFolderResult: []filemanager.File{{Name: "File A"}},
listByFolderTotal: 1,
listTrashByFolderResult: []filemanager.File{{Name: "File Trash"}},
listTrashByFolderTotal: 1,
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
row := &filemanager.File{Name: "Create.txt", FolderID: append([]byte(nil), folderID...), CreatedBy: []byte("actor-id")}
if err := svc.CreateFile(context.Background(), row); err != nil {
t.Fatalf("create file: %v", err)
}
if !fileRepo.createCalled || fileRepo.lastCreate != row {
t.Fatalf("expected CreateFile forwarded")
}
if !folderRepo.updateCalled {
t.Fatalf("expected CreateFile to touch parent folder")
}
if err := svc.UpdateFile(context.Background(), row); err != nil {
t.Fatalf("update file: %v", err)
}
if !fileRepo.updateCalled || fileRepo.lastUpdate != row {
t.Fatalf("expected UpdateFile forwarded")
}
id := []byte("file-id")
actor := []byte("actor-id")
purgeAt := time.Now().UTC().Add(24 * time.Hour)
if err := svc.DeleteFile(context.Background(), id, actor, &purgeAt); err != nil {
t.Fatalf("delete file: %v", err)
}
if !fileRepo.deleteCalled {
t.Fatalf("expected DeleteFile forwarded")
}
fileDeletedAt := time.Now().UTC()
fileRepo.getByIDAnyResult = &filemanager.File{
ID: id,
Name: "File Any",
FolderID: []byte("folder-id"),
Status: filemanager.FileStatusTrashed,
DeletedAt: &fileDeletedAt,
}
fileRepo.getByFolderNameResult = nil
fileRepo.updateCalled = false
folderRepo.getByIDResult = &filemanager.Folder{Name: "Folder A"}
if err := svc.RestoreFile(context.Background(), id, nil, false, actor); err != nil {
t.Fatalf("restore file: %v", err)
}
if !fileRepo.updateCalled {
t.Fatalf("expected RestoreFile to update file state")
}
if err := svc.PurgeFile(context.Background(), id); err != nil {
t.Fatalf("purge file: %v", err)
}
if !fileRepo.purgeCalled {
t.Fatalf("expected PurgeFile forwarded")
}
gotByID, err := svc.GetFileByID(context.Background(), id)
if err != nil {
t.Fatalf("get file by id: %v", err)
}
if gotByID == nil || gotByID.Name != "File A" {
t.Fatalf("unexpected file by id result")
}
gotByIDAny, err := svc.GetFileByIDAny(context.Background(), id)
if err != nil {
t.Fatalf("get file by id any: %v", err)
}
if gotByIDAny == nil || gotByIDAny.Name != "File Any" {
t.Fatalf("unexpected file by id any result")
}
gotByObjectKey, err := svc.GetFileByObjectKey(context.Background(), "fm/objects/x")
if err != nil {
t.Fatalf("get file by object key: %v", err)
}
if gotByObjectKey == nil || gotByObjectKey.Name != "File B" {
t.Fatalf("unexpected file by object key result")
}
fileRepo.getByFolderNameResult = &filemanager.File{Name: "File C"}
gotByName, err := svc.GetFileByFolderAndName(context.Background(), []byte("folder-id"), "file-c")
if err != nil {
t.Fatalf("get file by folder/name: %v", err)
}
if gotByName == nil || gotByName.Name != "File C" {
t.Fatalf("unexpected file by folder/name result")
}
rows, total, err := svc.ListFilesByFolder(context.Background(), []byte("folder-id"), "-size_bytes", 10, 0)
if err != nil {
t.Fatalf("list files: %v", err)
}
if total != 1 || len(rows) != 1 {
t.Fatalf("unexpected list files result")
}
if fileRepo.lastListSort != "size_bytes DESC" {
t.Fatalf("expected normalized file sort, got %q", fileRepo.lastListSort)
}
trashRows, trashTotal, err := svc.ListTrashFilesByFolder(context.Background(), []byte("folder-id"), "-created_at", 10, 0)
if err != nil {
t.Fatalf("list trash files: %v", err)
}
if trashTotal != 1 || len(trashRows) != 1 {
t.Fatalf("unexpected list trash files result")
}
if fileRepo.lastTrashListSort != "created_at DESC" {
t.Fatalf("expected normalized trash file sort, got %q", fileRepo.lastTrashListSort)
}
fileRepo.createErr = errors.New("create failed")
if err := svc.CreateFile(context.Background(), row); err == nil {
t.Fatalf("expected create error")
}
}
func TestFileManagerServiceListTrashNodes(t *testing.T) {
folderRepo := &fileManagerFolderRepoMock{
listTrashResult: []filemanager.Folder{{Name: "Folder Trash"}},
listTrashTotal: 1,
}
fileRepo := &fileManagerFileRepoMock{
listTrashByFolderResult: []filemanager.File{{Name: "File Trash"}},
listTrashByFolderTotal: 1,
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
folders, files, err := svc.ListTrashNodes(context.Background())
if err != nil {
t.Fatalf("list trash nodes: %v", err)
}
if len(folders) != 1 || len(files) != 1 {
t.Fatalf("unexpected trash node counts: folders=%d files=%d", len(folders), len(files))
}
if !folderRepo.listTrashCalled {
t.Fatalf("expected folder ListTrash to be called")
}
if folderRepo.lastListTrashSortGlobal != "deleted_at DESC" {
t.Fatalf("expected folder trash sort deleted_at DESC, got %q", folderRepo.lastListTrashSortGlobal)
}
if !fileRepo.listTrashByFolderCalled {
t.Fatalf("expected file ListTrashByFolder to be called")
}
if len(fileRepo.lastTrashListFolderID) != 0 {
t.Fatalf("expected file trash folder filter to be empty")
}
if fileRepo.lastTrashListSort != "deleted_at DESC" {
t.Fatalf("expected file trash sort deleted_at DESC, got %q", fileRepo.lastTrashListSort)
}
}
func TestFileManagerServiceListNodesByParent_RootIncludesSystemRootChildren(t *testing.T) {
rootID := []byte("root-folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: append([]byte(nil), rootID...),
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
listByParentFn: func(_ context.Context, parentID []byte, _ string, _, _ int) ([]filemanager.Folder, int64, error) {
if len(parentID) == 0 {
rows := []filemanager.Folder{
{ID: append([]byte(nil), rootID...), Name: filemanager.SystemRootFolderName, NameNormalized: filemanager.SystemRootFolderName},
{ID: []byte("public-folder-id"), Name: "Public", NameNormalized: "public"},
}
return rows, int64(len(rows)), nil
}
if string(parentID) == string(rootID) {
rows := []filemanager.Folder{
{ID: []byte("aircraft-folder-id"), Name: "aircraft", NameNormalized: "aircraft"},
}
return rows, int64(len(rows)), nil
}
return nil, 0, nil
},
}
fileRepo := &fileManagerFileRepoMock{
listByFolderResult: []filemanager.File{{ID: []byte("file-1"), Name: "image.jpg"}},
listByFolderTotal: 1,
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
folders, files, err := svc.ListNodesByParent(context.Background(), nil)
if err != nil {
t.Fatalf("list root nodes: %v", err)
}
if len(files) != 1 {
t.Fatalf("expected 1 file at root, got %d", len(files))
}
if len(folders) != 2 {
t.Fatalf("expected 2 folders (public + aircraft), got %d", len(folders))
}
if folderRepo.listByParentCalls != 2 {
t.Fatalf("expected ListByParent called twice for root listing, got %d", folderRepo.listByParentCalls)
}
if got := string(fileRepo.lastListFolderID); got != string(rootID) {
t.Fatalf("expected root folder id for file list, got %q", got)
}
if folders[0].Name != "aircraft" || folders[1].Name != "Public" {
t.Fatalf("expected root folders sorted and include aircraft/public, got %#v", folders)
}
}
func TestFileManagerServiceUploadFileNodesBulk_Success(t *testing.T) {
rootID := []byte("root-folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{}
storage := &fileManagerStorageMock{}
fileQueue := &fileProcessingQueueMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileProcessingQueue(fileQueue)
svc.SetFileStatusRealtimeEventWriter(fileRepo)
results := svc.UploadFileNodesBulk(context.Background(), []filemanager.UploadFileParams{
{Name: "invoice-1.pdf", ContentType: "application/pdf", SizeBytes: 12, Body: strings.NewReader("file-1")},
{Name: "invoice-2.pdf", ContentType: "application/pdf", SizeBytes: 12, Body: strings.NewReader("file-2")},
})
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
for i := range results {
if results[i].Err != nil {
t.Fatalf("expected success at index %d, got error %v", i, results[i].Err)
}
if results[i].File == nil {
t.Fatalf("expected file at index %d", i)
}
if results[i].File.Status != filemanager.FileStatusUploaded {
t.Fatalf("expected uploaded status at index %d, got %s", i, results[i].File.Status)
}
}
if storage.putCalls != 2 {
t.Fatalf("expected 2 storage put calls, got %d", storage.putCalls)
}
if !fileQueue.enqueueCalled {
t.Fatalf("expected enqueue called")
}
}
func TestFileManagerServiceUploadFileNodesBulk_PartialFailure(t *testing.T) {
rootID := []byte("root-folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{}
storage := &fileManagerStorageMock{
putErrAt: map[int]error{
2: errors.New("storage offline"),
},
}
fileQueue := &fileProcessingQueueMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileProcessingQueue(fileQueue)
results := svc.UploadFileNodesBulk(context.Background(), []filemanager.UploadFileParams{
{Name: "ok.pdf", ContentType: "application/pdf", SizeBytes: 2, Body: strings.NewReader("ok")},
{Name: "fail.pdf", ContentType: "application/pdf", SizeBytes: 4, Body: strings.NewReader("fail")},
})
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].Err != nil || results[0].File == nil {
t.Fatalf("expected first upload success, got err=%v", results[0].Err)
}
if results[1].Err == nil {
t.Fatalf("expected second upload failure")
}
}
func TestFileManagerServiceCreateFileNodesFromUploadBulk_Success(t *testing.T) {
files := map[string]*filemanager.File{
"upload-1": {
ID: []byte("upload-1"),
FolderID: []byte("root-folder"),
Name: "raw-1.pdf",
NameNormalized: "raw-1.pdf",
Status: filemanager.FileStatusValidated,
NameSlot: "tmp-slot-1",
},
"upload-2": {
ID: []byte("upload-2"),
FolderID: []byte("root-folder"),
Name: "raw-2.pdf",
NameNormalized: "raw-2.pdf",
Status: filemanager.FileStatusValidated,
NameSlot: "tmp-slot-2",
},
}
fileRepo := &fileManagerFileRepoMock{
getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
row := files[string(id)]
if row == nil {
return nil, nil
}
cp := *row
return &cp, nil
},
getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
for _, row := range files {
if row.DeletedAt == nil && row.NameSlot == "live" && string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
cp := *row
return &cp, nil
}
}
return nil, nil
},
finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
row := files[string(params.ID)]
if row == nil {
return nil
}
row.FolderID = append([]byte(nil), params.FolderID...)
row.Name = params.Name
row.NameNormalized = params.NameNormalized
row.Extension = params.Extension
row.Status = filemanager.FileStatusReady
row.NameSlot = "live"
return nil
},
}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
nameOverride := "final-2.pdf"
results := svc.CreateFileNodesFromUploadBulk(context.Background(), []filemanager.CreateFileFromUploadParams{
{UploadFileID: []byte("upload-1")},
{UploadFileID: []byte("upload-2"), Name: &nameOverride},
})
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
for i := range results {
if results[i].Err != nil {
t.Fatalf("expected success at index %d, got %v", i, results[i].Err)
}
if results[i].File == nil {
t.Fatalf("expected file at index %d", i)
}
if results[i].File.Status != filemanager.FileStatusReady {
t.Fatalf("expected ready status at index %d, got %s", i, results[i].File.Status)
}
}
if files["upload-1"].NameSlot != "live" || files["upload-2"].NameSlot != "live" {
t.Fatalf("expected finalized live name slots")
}
}
func TestFileManagerServiceCreateFileNodesFromUploadBulk_PartialFailure(t *testing.T) {
files := map[string]*filemanager.File{
"upload-1": {
ID: []byte("upload-1"),
FolderID: []byte("root-folder"),
Name: "raw.pdf",
NameNormalized: "raw.pdf",
Status: filemanager.FileStatusValidated,
NameSlot: "tmp-slot",
},
}
fileRepo := &fileManagerFileRepoMock{
getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
row := files[string(id)]
if row == nil {
return nil, nil
}
cp := *row
return &cp, nil
},
getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
for _, row := range files {
if row.DeletedAt == nil && row.NameSlot == "live" && string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
cp := *row
return &cp, nil
}
}
return nil, nil
},
finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
row := files[string(params.ID)]
if row == nil {
return nil
}
row.FolderID = append([]byte(nil), params.FolderID...)
row.Name = params.Name
row.NameNormalized = params.NameNormalized
row.Extension = params.Extension
row.Status = filemanager.FileStatusReady
row.NameSlot = "live"
return nil
},
}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
results := svc.CreateFileNodesFromUploadBulk(context.Background(), []filemanager.CreateFileFromUploadParams{
{UploadFileID: []byte("upload-1")},
{UploadFileID: []byte("upload-1")},
})
if len(results) != 2 {
t.Fatalf("expected 2 results, got %d", len(results))
}
if results[0].Err != nil {
t.Fatalf("expected first create success, got %v", results[0].Err)
}
if !errors.Is(results[1].Err, filemanager.ErrFileAlreadyFinalized) {
t.Fatalf("expected second create ErrFileAlreadyFinalized, got %v", results[1].Err)
}
}
func TestFileManagerServiceCreateFileNodeFromUpload_RequiresValidatedStatus(t *testing.T) {
fileRepo := &fileManagerFileRepoMock{
getByIDResult: &filemanager.File{
ID: []byte("upload-1"),
FolderID: []byte("root-folder"),
Name: "raw.pdf",
NameNormalized: "raw.pdf",
Status: filemanager.FileStatusUploaded,
NameSlot: "tmp-slot",
},
}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
_, err := svc.CreateFileNodeFromUpload(context.Background(), filemanager.CreateFileFromUploadParams{
UploadFileID: []byte("upload-1"),
})
if !errors.Is(err, filemanager.ErrFileNotEligibleForFinalize) {
t.Fatalf("expected ErrFileNotEligibleForFinalize, got %v", err)
}
if fileRepo.finalizeCalled {
t.Fatalf("expected finalize not called for non-eligible status")
}
}
func TestFileManagerServiceSingleUploadAndCreateCompatibility(t *testing.T) {
rootID := []byte("root-folder-id")
files := map[string]*filemanager.File{}
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{
createFn: func(_ context.Context, row *filemanager.File) error {
cp := *row
files[string(row.ID)] = &cp
return nil
},
getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
row := files[string(id)]
if row == nil {
return nil, nil
}
cp := *row
return &cp, nil
},
getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
for _, row := range files {
if row.DeletedAt == nil && row.NameSlot == "live" && string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
cp := *row
return &cp, nil
}
}
return nil, nil
},
finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
row := files[string(params.ID)]
if row == nil {
return nil
}
row.FolderID = append([]byte(nil), params.FolderID...)
row.Name = params.Name
row.NameNormalized = params.NameNormalized
row.Extension = params.Extension
row.Status = filemanager.FileStatusReady
row.NameSlot = "live"
return nil
},
}
storage := &fileManagerStorageMock{}
fileQueue := &fileProcessingQueueMock{}
actorID := uuidv7.MustBytes()
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileProcessingQueue(fileQueue)
svc.SetFileStatusRealtimeEventWriter(fileRepo)
uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "logo.png",
ContentType: "image/png",
SizeBytes: 8,
Body: strings.NewReader("png-data"),
ActorID: actorID,
})
if err != nil {
t.Fatalf("upload single failed: %v", err)
}
if uploaded == nil || uploaded.Status != filemanager.FileStatusUploaded {
t.Fatalf("expected uploaded status row")
}
// Simulate asynchronous file worker validation before finalize/create.
stored := files[string(uploaded.ID)]
if stored == nil {
t.Fatalf("expected uploaded row persisted")
}
stored.Status = filemanager.FileStatusValidated
finalName := "logo-final.png"
created, err := svc.CreateFileNodeFromUpload(context.Background(), filemanager.CreateFileFromUploadParams{
UploadFileID: uploaded.ID,
Name: &finalName,
ActorID: actorID,
})
if err != nil {
t.Fatalf("create from upload failed: %v", err)
}
if created == nil || created.Status != filemanager.FileStatusReady {
t.Fatalf("expected ready created row")
}
if created.Name != finalName {
t.Fatalf("expected final name %q, got %q", finalName, created.Name)
}
if len(fileRepo.realtimeEvents) != 2 {
t.Fatalf("expected 2 realtime events (uploaded + ready), got %d", len(fileRepo.realtimeEvents))
}
if got := filemanager.NormalizeFileStatus(fileRepo.realtimeEvents[0].Status); got != filemanager.FileStatusUploaded {
t.Fatalf("expected first realtime status uploaded, got %s", got)
}
if got := filemanager.NormalizeFileStatus(fileRepo.realtimeEvents[1].Status); got != filemanager.FileStatusReady {
t.Fatalf("expected second realtime status ready, got %s", got)
}
}
func TestFileManagerServiceUploadFileNode_QueuePublishFailureMarksFailed(t *testing.T) {
rootID := []byte("root-folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{}
storage := &fileManagerStorageMock{}
fileQueue := &fileProcessingQueueMock{
enqueueErr: errors.New("sqs unavailable"),
}
actorID := uuidv7.MustBytes()
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileProcessingQueue(fileQueue)
svc.SetFileStatusRealtimeEventWriter(fileRepo)
uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "draft.pdf",
ContentType: "application/pdf",
SizeBytes: 16,
Body: strings.NewReader("pdf"),
ActorID: actorID,
})
if err == nil {
t.Fatalf("expected upload error when enqueue fails")
}
if uploaded != nil {
t.Fatalf("expected nil uploaded row on enqueue failure")
}
if storage.putCalls != 1 {
t.Fatalf("expected storage put called once")
}
if !fileQueue.enqueueCalled {
t.Fatalf("expected queue enqueue called")
}
if !fileRepo.transitionStatusCalled {
t.Fatalf("expected transition status called to mark failed")
}
if filemanager.NormalizeFileStatus(fileRepo.lastTransitionStatus.ExpectedFrom) != filemanager.FileStatusUploaded {
t.Fatalf("expected transition from uploaded, got %q", fileRepo.lastTransitionStatus.ExpectedFrom)
}
if filemanager.NormalizeFileStatus(fileRepo.lastTransitionStatus.To) != filemanager.FileStatusFailed {
t.Fatalf("expected transition to failed, got %q", fileRepo.lastTransitionStatus.To)
}
if len(fileRepo.realtimeEvents) < 2 {
t.Fatalf("expected realtime events for uploaded + failed, got %d", len(fileRepo.realtimeEvents))
}
last := fileRepo.realtimeEvents[len(fileRepo.realtimeEvents)-1]
if got := filemanager.NormalizeFileStatus(last.Status); got != filemanager.FileStatusFailed {
t.Fatalf("expected last realtime status failed, got %s", got)
}
}
func TestFileManagerServiceUploadFileNode_OutboxDurablePath(t *testing.T) {
rootID := []byte("root-folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))
uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "draft.pdf",
ContentType: "application/pdf",
SizeBytes: 16,
Body: strings.NewReader("pdf"),
})
if err != nil {
t.Fatalf("expected upload success, got %v", err)
}
if uploaded == nil {
t.Fatalf("expected uploaded row")
}
if uploaded.Status != filemanager.FileStatusUploaded {
t.Fatalf("expected status uploaded, got %s", uploaded.Status)
}
if !fileRepo.withTransactionCalled {
t.Fatalf("expected transaction path to be used")
}
if len(fileRepo.outboxMessages) != 1 {
t.Fatalf("expected one outbox message, got %d", len(fileRepo.outboxMessages))
}
if fileRepo.transitionStatusCalled {
t.Fatalf("expected no failed transition on successful outbox path")
}
if storage.deleteCalls != 0 {
t.Fatalf("expected no storage cleanup on successful upload")
}
}
func TestFileManagerServiceUploadFileNode_OutboxFailureRollsBackAndCleansStorage(t *testing.T) {
rootID := []byte("root-folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{
outboxErr: errors.New("outbox write failed"),
}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))
uploaded, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "draft.pdf",
ContentType: "application/pdf",
SizeBytes: 16,
Body: strings.NewReader("pdf"),
})
if err == nil {
t.Fatalf("expected upload error when outbox persist fails")
}
if uploaded != nil {
t.Fatalf("expected nil uploaded row on outbox failure")
}
if !fileRepo.withTransactionCalled {
t.Fatalf("expected transaction path to be used")
}
if storage.putCalls != 1 {
t.Fatalf("expected storage put called once")
}
if storage.deleteCalls != 1 {
t.Fatalf("expected storage cleanup after outbox failure, got %d", storage.deleteCalls)
}
if fileRepo.transitionStatusCalled {
t.Fatalf("expected no transition-to-failed when transactional outbox persistence fails")
}
}
func TestFileManagerServiceUploadFileNode_WithUploadPolicy(t *testing.T) {
newUploadService := func(policy filemanager.FileUploadPolicy) (*FileManagerService, *fileManagerStorageMock, *fileManagerFileRepoMock) {
rootID := []byte("root-folder-id")
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
getByIDAnyResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetUploadPolicy(policy)
svc.SetFileProcessingQueue(&fileProcessingQueueMock{})
return svc, storage, fileRepo
}
t.Run("upload success when all policy checks pass", func(t *testing.T) {
svc, storage, fileRepo := newUploadService(filemanager.FileUploadPolicy{
MaxFileSizeBytes: 1024,
AllowedMimeTypes: []string{"image/png"},
AllowedExtensions: []string{".png"},
AllowEmptyFile: false,
MaxFilenameLength: 128,
})
row, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "Logo.PNG",
ContentType: "image/png; charset=utf-8",
SizeBytes: 8,
Body: strings.NewReader("png-data"),
})
if err != nil {
t.Fatalf("upload failed: %v", err)
}
if row == nil {
t.Fatalf("expected uploaded row")
}
if row.Status != filemanager.FileStatusUploaded {
t.Fatalf("expected status uploaded, got %s", row.Status)
}
if storage.putCalls != 1 {
t.Fatalf("expected 1 storage put call, got %d", storage.putCalls)
}
if !fileRepo.createCalled {
t.Fatalf("expected db create called")
}
})
t.Run("reject file too large", func(t *testing.T) {
svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
MaxFileSizeBytes: 128,
})
_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "large.bin",
SizeBytes: 129,
Body: strings.NewReader("data"),
})
if !errors.Is(err, filemanager.ErrFileTooLarge) {
t.Fatalf("expected ErrFileTooLarge, got %v", err)
}
if storage.putCalls != 0 {
t.Fatalf("expected no storage calls")
}
})
t.Run("reject mime type not allowed", func(t *testing.T) {
svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
AllowedMimeTypes: []string{"image/png"},
})
_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "doc.pdf",
ContentType: "application/pdf",
SizeBytes: 10,
Body: strings.NewReader("pdf"),
})
if !errors.Is(err, filemanager.ErrFileMimeTypeNotAllowed) {
t.Fatalf("expected ErrFileMimeTypeNotAllowed, got %v", err)
}
if storage.putCalls != 0 {
t.Fatalf("expected no storage calls")
}
})
t.Run("reject extension not allowed", func(t *testing.T) {
svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
AllowedExtensions: []string{"png"},
})
_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "doc.pdf",
SizeBytes: 10,
Body: strings.NewReader("pdf"),
})
if !errors.Is(err, filemanager.ErrFileExtensionNotAllowed) {
t.Fatalf("expected ErrFileExtensionNotAllowed, got %v", err)
}
if storage.putCalls != 0 {
t.Fatalf("expected no storage calls")
}
})
t.Run("reject empty file when not allowed", func(t *testing.T) {
svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
AllowEmptyFile: false,
})
_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "empty.txt",
SizeBytes: 0,
Body: strings.NewReader(""),
})
if !errors.Is(err, filemanager.ErrEmptyFileNotAllowed) {
t.Fatalf("expected ErrEmptyFileNotAllowed, got %v", err)
}
if storage.putCalls != 0 {
t.Fatalf("expected no storage calls")
}
})
t.Run("reject file name too long", func(t *testing.T) {
svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
MaxFilenameLength: 8,
})
_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "this-name-is-too-long.txt",
SizeBytes: 12,
Body: strings.NewReader("payload"),
})
if !errors.Is(err, filemanager.ErrFileNameTooLong) {
t.Fatalf("expected ErrFileNameTooLong, got %v", err)
}
if storage.putCalls != 0 {
t.Fatalf("expected no storage calls")
}
})
t.Run("empty allowed-list config allows mime and extension", func(t *testing.T) {
svc, storage, _ := newUploadService(filemanager.FileUploadPolicy{
MaxFileSizeBytes: 1024,
MaxFilenameLength: 255,
})
_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "archive.unknownext",
ContentType: "application/x-custom",
SizeBytes: 11,
Body: strings.NewReader("custom-data"),
})
if err != nil {
t.Fatalf("expected upload allowed for empty allow-list config, got %v", err)
}
if storage.putCalls != 1 {
t.Fatalf("expected storage put called once")
}
})
}
func TestFileManagerServiceRequestFileUploadIntent(t *testing.T) {
t.Run("success", func(t *testing.T) {
fileRepo := &fileManagerFileRepoMock{}
intentRepo := &fileUploadIntentRepoMock{}
storage := &fileManagerStorageMock{
presignPut: filemanager.PresignedPutObject{
Bucket: "wucher-file-dev",
ObjectKey: "files/2026/04/05/object.pdf",
Method: "PUT",
URL: "https://example.local/upload",
Headers: map[string]string{"content-type": "application/pdf"},
ExpiresAt: time.Now().UTC().Add(10 * time.Minute),
},
}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileUploadIntentRepository(intentRepo)
svc.SetUploadPolicy(filemanager.FileUploadPolicy{
MaxFileSizeBytes: 1024 * 1024,
AllowedMimeTypes: []string{"application/pdf"},
AllowedExtensions: []string{"pdf"},
AllowEmptyFile: false,
})
grant, err := svc.RequestFileUploadIntent(context.Background(), filemanager.RequestFileUploadIntentParams{
Name: "policy.pdf",
ContentType: "application/pdf; charset=utf-8",
SizeBytes: 120,
ActorID: uuidv7.MustBytes(),
})
if err != nil {
t.Fatalf("RequestFileUploadIntent() error = %v", err)
}
if grant == nil {
t.Fatalf("expected upload intent grant")
}
if !intentRepo.createCalled {
t.Fatalf("expected upload intent to be persisted")
}
if storage.presignPutCalls != 1 {
t.Fatalf("expected presign put called once, got %d", storage.presignPutCalls)
}
if intentRepo.lastCreate == nil || intentRepo.lastCreate.Status != filemanager.FileUploadIntentStatusPending {
t.Fatalf("expected pending upload intent status")
}
if grant.Method != "PUT" {
t.Fatalf("expected PUT method, got %q", grant.Method)
}
})
t.Run("reject invalid policy before presign", func(t *testing.T) {
fileRepo := &fileManagerFileRepoMock{}
intentRepo := &fileUploadIntentRepoMock{}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileUploadIntentRepository(intentRepo)
svc.SetUploadPolicy(filemanager.FileUploadPolicy{
MaxFileSizeBytes: 100,
})
grant, err := svc.RequestFileUploadIntent(context.Background(), filemanager.RequestFileUploadIntentParams{
Name: "large.bin",
ContentType: "application/octet-stream",
SizeBytes: 200,
ActorID: uuidv7.MustBytes(),
})
if !errors.Is(err, filemanager.ErrFileTooLarge) {
t.Fatalf("expected ErrFileTooLarge, got %v", err)
}
if grant != nil {
t.Fatalf("expected nil grant on policy failure")
}
if storage.presignPutCalls != 0 {
t.Fatalf("expected no presign call when policy validation fails")
}
if intentRepo.createCalled {
t.Fatalf("expected no upload intent persistence when policy validation fails")
}
})
}
func TestFileManagerServiceCompleteFileUploadIntent(t *testing.T) {
newService := func(headMeta filemanager.ObjectMetadata, headErr error) (*FileManagerService, *fileManagerFileRepoMock, *fileManagerStorageMock, []byte) {
rootID := uuidv7.MustBytes()
intentID := uuidv7.MustBytes()
actorID := uuidv7.MustBytes()
intent := &filemanager.FileUploadIntent{
ID: intentID,
Name: "evidence.pdf",
NameNormalized: "evidence.pdf",
Extension: "pdf",
SizeBytes: 42,
MimeType: "application/pdf",
Bucket: "test-bucket",
ObjectKey: "files/2026/04/05/evidence.pdf",
Status: filemanager.FileUploadIntentStatusPending,
ExpiresAt: time.Now().UTC().Add(5 * time.Minute),
CreatedBy: actorID,
UpdatedBy: actorID,
}
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
getByIDAnyResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{
uploadIntentByID: map[string]*filemanager.FileUploadIntent{
string(intentID): intent,
},
}
storage := &fileManagerStorageMock{
headMeta: headMeta,
headErr: headErr,
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileUploadIntentRepository(fileRepo)
svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))
return svc, fileRepo, storage, intentID
}
t.Run("success persists uploaded file and outbox", func(t *testing.T) {
svc, fileRepo, _, intentID := newService(filemanager.ObjectMetadata{
Bucket: "test-bucket",
ObjectKey: "files/2026/04/05/evidence.pdf",
SizeBytes: 42,
ContentType: "application/pdf",
}, nil)
row, err := svc.CompleteFileUploadIntent(context.Background(), filemanager.CompleteFileUploadIntentParams{
UploadIntentID: intentID,
ActorID: uuidv7.MustBytes(),
})
if err != nil {
t.Fatalf("CompleteFileUploadIntent() error = %v", err)
}
if row == nil {
t.Fatalf("expected completed file row")
}
if row.Status != filemanager.FileStatusUploaded {
t.Fatalf("expected status uploaded, got %q", row.Status)
}
if !fileRepo.withTransactionCalled {
t.Fatalf("expected transactional durable path")
}
if !fileRepo.createCalled {
t.Fatalf("expected file metadata create call")
}
if len(fileRepo.outboxMessages) != 1 {
t.Fatalf("expected one outbox message, got %d", len(fileRepo.outboxMessages))
}
intent := fileRepo.uploadIntentByID[string(intentID)]
if intent == nil || intent.Status != filemanager.FileUploadIntentStatusCompleted {
t.Fatalf("expected upload intent completed")
}
})
t.Run("fails when uploaded object is missing", func(t *testing.T) {
svc, fileRepo, _, intentID := newService(filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound)
row, err := svc.CompleteFileUploadIntent(context.Background(), filemanager.CompleteFileUploadIntentParams{
UploadIntentID: intentID,
ActorID: uuidv7.MustBytes(),
})
if !errors.Is(err, filemanager.ErrUploadObjectNotFound) {
t.Fatalf("expected ErrUploadObjectNotFound, got %v", err)
}
if row != nil {
t.Fatalf("expected nil row when object not found")
}
if fileRepo.createCalled {
t.Fatalf("expected no metadata row create when object verification fails")
}
if fileRepo.withTransactionCalled {
t.Fatalf("expected no transaction when object verification fails")
}
})
}
func TestFileManagerServiceCreateFileNodeFromUploadIntent(t *testing.T) {
newService := func(headMeta filemanager.ObjectMetadata, headErr error) (*FileManagerService, *fileManagerFolderRepoMock, *fileManagerFileRepoMock, []byte, []byte) {
rootID := uuidv7.MustBytes()
intentID := uuidv7.MustBytes()
actorID := uuidv7.MustBytes()
intent := &filemanager.FileUploadIntent{
ID: intentID,
Name: "upload-source.pdf",
NameNormalized: "upload-source.pdf",
Extension: "pdf",
SizeBytes: 42,
MimeType: "application/pdf",
Bucket: "test-bucket",
ObjectKey: "files/2026/04/05/upload-source.pdf",
Status: filemanager.FileUploadIntentStatusPending,
ExpiresAt: time.Now().UTC().Add(5 * time.Minute),
CreatedBy: actorID,
UpdatedBy: actorID,
}
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
getByIDAnyResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
fileRepo := &fileManagerFileRepoMock{
uploadIntentByID: map[string]*filemanager.FileUploadIntent{
string(intentID): intent,
},
}
stored := &filemanager.File{}
fileRepo.getByFolderNameFn = func(_ context.Context, _ []byte, nameNormalized string) (*filemanager.File, error) {
if len(stored.ID) == 0 {
return nil, nil
}
if stored.NameNormalized == nameNormalized {
cp := *stored
return &cp, nil
}
return nil, nil
}
fileRepo.createFn = func(_ context.Context, row *filemanager.File) error {
if len(row.ID) == 0 {
row.ID = uuidv7.MustBytes()
}
cp := *row
cp.ID = append([]byte(nil), row.ID...)
cp.FolderID = append([]byte(nil), row.FolderID...)
*stored = cp
fileRepo.getByIDAnyResult = stored
fileRepo.getByIDResult = stored
return nil
}
fileRepo.updateFn = func(_ context.Context, row *filemanager.File) error {
cp := *row
cp.ID = append([]byte(nil), row.ID...)
cp.FolderID = append([]byte(nil), row.FolderID...)
*stored = cp
fileRepo.getByIDAnyResult = stored
fileRepo.getByIDResult = stored
return nil
}
storage := &fileManagerStorageMock{
headMeta: headMeta,
headErr: headErr,
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
svc.SetFileUploadIntentRepository(fileRepo)
svc.SetFileProcessingOutbox(fileRepo, queue.NewFileProcessingJSONSerializer("v1", queue.QueueTypeStandard, ""))
return svc, folderRepo, fileRepo, intentID, actorID
}
t.Run("success", func(t *testing.T) {
svc, _, fileRepo, intentID, actorID := newService(filemanager.ObjectMetadata{
Bucket: "test-bucket",
ObjectKey: "files/2026/04/05/upload-source.pdf",
SizeBytes: 42,
ContentType: "application/pdf",
}, nil)
row, err := svc.CreateFileNodeFromUploadIntent(context.Background(), filemanager.CreateFileFromUploadIntentParams{
UploadIntentID: intentID,
Name: "policy-final.pdf",
ActorID: actorID,
IsTemplate: true,
})
if err != nil {
t.Fatalf("CreateFileNodeFromUploadIntent() error = %v", err)
}
if row == nil {
t.Fatalf("expected created file row")
}
if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusUploaded {
t.Fatalf("expected uploaded status, got %q", row.Status)
}
if row.Name != "policy-final.pdf" {
t.Fatalf("expected renamed file, got %q", row.Name)
}
if !row.IsTemplate {
t.Fatalf("expected template flag to be persisted")
}
if !strings.EqualFold(strings.TrimSpace(row.NameSlot), "live") {
t.Fatalf("expected live name slot, got %q", row.NameSlot)
}
if !fileRepo.withTransactionCalled {
t.Fatalf("expected durable transaction path")
}
if len(fileRepo.outboxMessages) != 1 {
t.Fatalf("expected outbox message created, got %d", len(fileRepo.outboxMessages))
}
if !fileRepo.updateCalled {
t.Fatalf("expected file metadata move/update call")
}
intent := fileRepo.uploadIntentByID[string(intentID)]
if intent == nil || intent.Status != filemanager.FileUploadIntentStatusCompleted {
t.Fatalf("expected upload intent completed")
}
})
t.Run("auto rename when target name already exists", func(t *testing.T) {
svc, _, fileRepo, intentID, actorID := newService(filemanager.ObjectMetadata{
Bucket: "test-bucket",
ObjectKey: "files/2026/04/05/upload-source.pdf",
SizeBytes: 42,
ContentType: "application/pdf",
}, nil)
existingID := uuidv7.MustBytes()
fileRepo.getByFolderNameFn = func(_ context.Context, _ []byte, nameNormalized string) (*filemanager.File, error) {
if nameNormalized == "policy-final.pdf" {
return &filemanager.File{
ID: existingID,
Name: "policy-final.pdf",
NameNormalized: "policy-final.pdf",
NameSlot: "live",
}, nil
}
return nil, nil
}
row, err := svc.CreateFileNodeFromUploadIntent(context.Background(), filemanager.CreateFileFromUploadIntentParams{
UploadIntentID: intentID,
Name: "policy-final.pdf",
ActorID: actorID,
IsTemplate: false,
})
if err != nil {
t.Fatalf("CreateFileNodeFromUploadIntent() error = %v", err)
}
if row == nil {
t.Fatalf("expected created file row")
}
if row.Name != "policy-final (1).pdf" {
t.Fatalf("expected auto renamed file, got %q", row.Name)
}
if filemanager.NormalizeFileStatus(row.Status) != filemanager.FileStatusUploaded {
t.Fatalf("expected uploaded status, got %q", row.Status)
}
if !strings.EqualFold(strings.TrimSpace(row.NameSlot), "live") {
t.Fatalf("expected live name slot, got %q", row.NameSlot)
}
})
t.Run("fails when uploaded object is missing", func(t *testing.T) {
svc, _, fileRepo, intentID, actorID := newService(filemanager.ObjectMetadata{}, filemanager.ErrUploadObjectNotFound)
row, err := svc.CreateFileNodeFromUploadIntent(context.Background(), filemanager.CreateFileFromUploadIntentParams{
UploadIntentID: intentID,
Name: "policy-final.pdf",
ActorID: actorID,
})
if !errors.Is(err, filemanager.ErrUploadObjectNotFound) {
t.Fatalf("expected ErrUploadObjectNotFound, got %v", err)
}
if row != nil {
t.Fatalf("expected nil row when object not found")
}
if fileRepo.createCalled {
t.Fatalf("expected no file metadata create when object verification fails")
}
if fileRepo.withTransactionCalled {
t.Fatalf("expected no durable transaction when object verification fails")
}
})
}
func TestFileManagerServiceCreateFileNodeFromUpload_AutoRenameDuplicate(t *testing.T) {
uploadID := []byte("upload-1")
targetFolderID := []byte("folder-1")
existingID := []byte("existing-1")
files := map[string]*filemanager.File{
string(uploadID): {
ID: append([]byte(nil), uploadID...),
FolderID: append([]byte(nil), targetFolderID...),
Name: "report.pdf",
NameNormalized: "report.pdf",
Status: filemanager.FileStatusValidated,
NameSlot: "tmp-slot",
},
string(existingID): {
ID: append([]byte(nil), existingID...),
FolderID: append([]byte(nil), targetFolderID...),
Name: "report.pdf",
NameNormalized: "report.pdf",
Status: filemanager.FileStatusReady,
NameSlot: "live",
},
}
fileRepo := &fileManagerFileRepoMock{
getByIDFn: func(_ context.Context, id []byte) (*filemanager.File, error) {
row := files[string(id)]
if row == nil {
return nil, nil
}
cp := *row
return &cp, nil
},
getByFolderNameFn: func(_ context.Context, folderID []byte, nameNormalized string) (*filemanager.File, error) {
for _, row := range files {
if row.DeletedAt != nil || row.NameSlot != "live" {
continue
}
if string(row.FolderID) == string(folderID) && row.NameNormalized == nameNormalized {
cp := *row
return &cp, nil
}
}
return nil, nil
},
finalizeFn: func(_ context.Context, params filemanager.FileFinalizeParams) error {
row := files[string(params.ID)]
if row == nil {
return nil
}
row.FolderID = append([]byte(nil), params.FolderID...)
row.Name = params.Name
row.NameNormalized = params.NameNormalized
row.Extension = params.Extension
row.Status = filemanager.FileStatusReady
row.NameSlot = "live"
return nil
},
}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
targetName := "report.pdf"
created, err := svc.CreateFileNodeFromUpload(context.Background(), filemanager.CreateFileFromUploadParams{
UploadFileID: uploadID,
Name: &targetName,
ActorID: uuidv7.MustBytes(),
})
if err != nil {
t.Fatalf("CreateFileNodeFromUpload() error = %v", err)
}
if created == nil {
t.Fatalf("expected created file")
}
if created.Name != "report (1).pdf" {
t.Fatalf("expected auto renamed file, got %q", created.Name)
}
}
func TestNormalizeFolderSort(t *testing.T) {
cases := map[string]string{
"name": "name ASC",
"-name": "name DESC",
"depth": "depth ASC",
"-depth": "depth DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"updated_by": "updated_at ASC",
"-updated_by": "updated_at DESC",
"unknown": "name ASC",
"": "name ASC",
}
for in, want := range cases {
if got := normalizeFolderSort(in); got != want {
t.Fatalf("normalizeFolderSort(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeFileSort(t *testing.T) {
cases := map[string]string{
"name": "name ASC",
"-name": "name DESC",
"size_bytes": "size_bytes ASC",
"-size_bytes": "size_bytes DESC",
"mime_type": "mime_type ASC",
"-mime_type": "mime_type DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"updated_by": "updated_at ASC",
"-updated_by": "updated_at DESC",
"unknown": "name ASC",
"": "name ASC",
}
for in, want := range cases {
if got := normalizeFileSort(in); got != want {
t.Fatalf("normalizeFileSort(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeAttachmentSort(t *testing.T) {
cases := map[string]string{
"sort_order": "sort_order ASC, created_at DESC",
"-sort_order": "sort_order DESC, created_at DESC",
"created_at": "created_at ASC",
"-created_at": "created_at DESC",
"updated_at": "updated_at ASC",
"-updated_at": "updated_at DESC",
"updated_by": "updated_at ASC",
"-updated_by": "updated_at DESC",
"unknown": "sort_order ASC, created_at DESC",
"": "sort_order ASC, created_at DESC",
}
for in, want := range cases {
if got := normalizeAttachmentSort(in); got != want {
t.Fatalf("normalizeAttachmentSort(%q) = %q, want %q", in, got, want)
}
}
}
func TestFileManagerServiceDeleteFile_EmitsTrashedRealtimeEvent(t *testing.T) {
fileID := uuidv7.MustBytes()
actorID := uuidv7.MustBytes()
fileRepo := &fileManagerFileRepoMock{
getByIDAnyResult: &filemanager.File{
ID: append([]byte(nil), fileID...),
Name: "logo.jpg",
FolderID: uuidv7.MustBytes(),
Status: filemanager.FileStatusUploaded,
CreatedBy: append([]byte(nil), actorID...),
},
}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetFileStatusRealtimeEventWriter(fileRepo)
if err := svc.DeleteFile(context.Background(), fileID, actorID, nil); err != nil {
t.Fatalf("delete file: %v", err)
}
if len(fileRepo.realtimeEvents) != 1 {
t.Fatalf("expected 1 realtime event, got %d", len(fileRepo.realtimeEvents))
}
event := fileRepo.realtimeEvents[0]
if got := filemanager.NormalizeFileStatus(event.Status); got != filemanager.FileStatusTrashed {
t.Fatalf("expected trashed realtime status, got %s", got)
}
if string(event.FileID) != string(fileID) {
t.Fatalf("expected realtime event file id to match deleted file")
}
if string(event.UserID) != string(actorID) {
t.Fatalf("expected realtime event user id to match actor")
}
}
func TestFileManagerServiceRestoreFile_EmitsReadyRealtimeEvent(t *testing.T) {
fileID := uuidv7.MustBytes()
folderID := uuidv7.MustBytes()
actorID := uuidv7.MustBytes()
deletedAt := time.Now().UTC().Add(-time.Minute)
fileRepo := &fileManagerFileRepoMock{
getByIDAnyResult: &filemanager.File{
ID: append([]byte(nil), fileID...),
Name: "logo.jpg",
NameNormalized: "logo.jpg",
FolderID: append([]byte(nil), folderID...),
Status: filemanager.FileStatusTrashed,
DeletedAt: &deletedAt,
CreatedBy: append([]byte(nil), actorID...),
},
}
folderRepo := &fileManagerFolderRepoMock{
getByIDResult: &filemanager.Folder{ID: append([]byte(nil), folderID...), Name: "Assets"},
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetFileStatusRealtimeEventWriter(fileRepo)
if err := svc.RestoreFile(context.Background(), fileID, nil, false, actorID); err != nil {
t.Fatalf("restore file: %v", err)
}
if len(fileRepo.realtimeEvents) != 1 {
t.Fatalf("expected 1 realtime event, got %d", len(fileRepo.realtimeEvents))
}
event := fileRepo.realtimeEvents[0]
if got := filemanager.NormalizeFileStatus(event.Status); got != filemanager.FileStatusReady {
t.Fatalf("expected ready realtime status, got %s", got)
}
if string(event.FileID) != string(fileID) {
t.Fatalf("expected realtime event file id to match restored file")
}
if string(event.UserID) != string(actorID) {
t.Fatalf("expected realtime event user id to match actor")
}
}
func TestFileManagerServiceDeleteFile_TouchesParentFolderUpdatedAt(t *testing.T) {
fileID := uuidv7.MustBytes()
folderID := uuidv7.MustBytes()
actorID := uuidv7.MustBytes()
fileRepo := &fileManagerFileRepoMock{
getByIDAnyResult: &filemanager.File{
ID: append([]byte(nil), fileID...),
FolderID: append([]byte(nil), folderID...),
Status: filemanager.FileStatusReady,
},
}
folderRepo := &fileManagerFolderRepoMock{
getByIDAnyResult: &filemanager.Folder{
ID: append([]byte(nil), folderID...),
},
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
if err := svc.DeleteFile(context.Background(), fileID, actorID, nil); err != nil {
t.Fatalf("delete file: %v", err)
}
if !folderRepo.updateCalled {
t.Fatalf("expected parent folder to be touched")
}
if folderRepo.lastUpdate == nil || folderRepo.lastUpdate.UpdatedAt.IsZero() {
t.Fatalf("expected folder updated_at to be set")
}
if string(folderRepo.lastUpdate.UpdatedBy) != string(actorID) {
t.Fatalf("expected folder updated_by to match actor")
}
}
func TestFileManagerServiceMoveFileNode_TouchesSourceAndTargetFolders(t *testing.T) {
fileID := uuidv7.MustBytes()
sourceFolderID := uuidv7.MustBytes()
targetFolderID := uuidv7.MustBytes()
actorID := uuidv7.MustBytes()
fileRepo := &fileManagerFileRepoMock{
getByIDAnyResult: &filemanager.File{
ID: append([]byte(nil), fileID...),
FolderID: append([]byte(nil), sourceFolderID...),
Name: "doc.pdf",
NameNormalized: "doc.pdf",
},
getByIDResult: &filemanager.File{
ID: append([]byte(nil), fileID...),
FolderID: append([]byte(nil), targetFolderID...),
Name: "doc.pdf",
NameNormalized: "doc.pdf",
},
}
folderRepo := &fileManagerFolderRepoMock{
getByIDAnyResult: &filemanager.Folder{
ID: uuidv7.MustBytes(),
},
}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{})
_, err := svc.MoveFileNode(context.Background(), fileID, filemanager.MoveFileParams{
FolderID: targetFolderID,
FolderProvided: true,
ActorID: actorID,
})
if err != nil {
t.Fatalf("move file: %v", err)
}
if folderRepo.updateCalls != 2 {
t.Fatalf("expected both source and target folders touched, got %d updates", folderRepo.updateCalls)
}
}
func TestFileManagerServiceUploadFileNode_SetsTempLifecycleAndTag(t *testing.T) {
rootID := uuidv7.MustBytes()
fileRepo := &fileManagerFileRepoMock{
getByIDResult: &filemanager.File{
ID: uuidv7.MustBytes(),
FolderID: rootID,
Name: "proof.pdf",
NameNormalized: "proof.pdf",
ObjectKey: "files/2026/04/27/proof.pdf",
Status: filemanager.FileStatusUploaded,
},
}
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{
EnableS3Tagging: true,
})
svc.SetObjectStorage(storage)
svc.SetFileProcessingQueue(&fileProcessingQueueMock{})
_, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "proof.pdf",
ContentType: "application/pdf",
SizeBytes: 10,
Body: strings.NewReader("0123456789"),
})
if err != nil {
t.Fatalf("UploadFileNode() error = %v", err)
}
if !fileRepo.updateLifecycleCalled {
t.Fatalf("expected lifecycle update to be called")
}
if storage.taggingCalls == 0 {
t.Fatalf("expected object tagging to be called")
}
if storage.lastTags["gc"] != "temp" {
t.Fatalf("expected gc=temp tag, got %v", storage.lastTags)
}
}
func TestFileManagerServiceReconcileFileLifecycle_MarksOrphanPendingWhenUnreferenced(t *testing.T) {
fileID := uuidv7.MustBytes()
fileRepo := &fileManagerFileRepoMock{
getByIDAnyResult: &filemanager.File{
ID: append([]byte(nil), fileID...),
ObjectKey: "files/2026/04/27/a.pdf",
LifecycleStatus: filemanager.FileLifecycleStatusActive,
},
isFileReferencedResult: false,
}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{
EnableS3Tagging: true,
})
svc.SetObjectStorage(storage)
if err := svc.ReconcileFileLifecycle(context.Background(), fileID, uuidv7.MustBytes()); err != nil {
t.Fatalf("ReconcileFileLifecycle() error = %v", err)
}
if !fileRepo.updateLifecycleCalled {
t.Fatalf("expected lifecycle update called")
}
if storage.lastTags["gc"] != "keep" {
t.Fatalf("expected gc=keep tag, got %v", storage.lastTags)
}
}
func TestFileManagerServiceCleanupOrphanPendingFiles_DryRunAndDelete(t *testing.T) {
fileID := uuidv7.MustBytes()
orphanedAt := time.Now().UTC().Add(-48 * time.Hour)
fileRepo := &fileManagerFileRepoMock{
listByLifecycleResult: []filemanager.File{{
ID: append([]byte(nil), fileID...),
ObjectKey: "files/2026/04/25/a.pdf",
LifecycleStatus: filemanager.FileLifecycleStatusOrphanPending,
OrphanedAt: &orphanedAt,
}},
isFileReferencedResult: false,
}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{
EnableS3Tagging: true,
})
svc.SetObjectStorage(storage)
stats, err := svc.CleanupOrphanPendingFiles(context.Background(), time.Now().UTC(), 24*time.Hour, 10, true)
if err != nil {
t.Fatalf("dry-run cleanup error = %v", err)
}
if stats.DryRun != true || stats.Scanned != 1 || stats.Deleted != 0 {
t.Fatalf("unexpected dry-run stats: %+v", stats)
}
stats, err = svc.CleanupOrphanPendingFiles(context.Background(), time.Now().UTC(), 24*time.Hour, 10, false)
if err != nil {
t.Fatalf("cleanup error = %v", err)
}
if stats.Deleted != 1 {
t.Fatalf("expected 1 deleted file, got %+v", stats)
}
if storage.deleteCalls == 0 {
t.Fatalf("expected s3 delete called")
}
}
func TestFileManagerServicePurgeFileNode_DeletesThumbnailObject(t *testing.T) {
fileID := uuidv7.MustBytes()
deletedAt := time.Now().UTC().Add(-time.Minute)
thumbKey := "files/2026/04/27/thumb-photo.jpg"
objectKey := "files/2026/04/27/photo.jpg"
fileRepo := &fileManagerFileRepoMock{
getByIDAnyResult: &filemanager.File{
ID: append([]byte(nil), fileID...),
ObjectKey: objectKey,
ThumbnailObjectKey: &thumbKey,
DeletedAt: &deletedAt,
},
}
storage := &fileManagerStorageMock{}
svc := NewFileManagerService(&fileManagerFolderRepoMock{}, fileRepo, nil, FileManagerServiceDependencies{})
svc.SetObjectStorage(storage)
if err := svc.PurgeFileNode(context.Background(), fileID); err != nil {
t.Fatalf("purge file node: %v", err)
}
if !fileRepo.purgeCalled {
t.Fatalf("expected purge repository called")
}
deadline := time.Now().Add(300 * time.Millisecond)
for time.Now().Before(deadline) {
if storage.deleteCalls >= 2 {
break
}
time.Sleep(10 * time.Millisecond)
}
if storage.deleteCalls != 2 {
t.Fatalf("expected 2 deleted objects (main+thumb), got %d", storage.deleteCalls)
}
hasObject := false
hasThumb := false
for i := range storage.deletedKeys {
if storage.deletedKeys[i] == objectKey {
hasObject = true
}
if storage.deletedKeys[i] == thumbKey {
hasThumb = true
}
}
if !hasObject || !hasThumb {
t.Fatalf("expected deleted keys to include main and thumb, got %#v", storage.deletedKeys)
}
}
func TestFileManagerServiceUploadFileNode_TaggingFailureDoesNotFailUpload(t *testing.T) {
rootID := uuidv7.MustBytes()
fileRepo := &fileManagerFileRepoMock{
getByIDResult: &filemanager.File{
ID: uuidv7.MustBytes(),
FolderID: rootID,
Name: "proof.pdf",
NameNormalized: "proof.pdf",
ObjectKey: "files/2026/04/27/proof.pdf",
Status: filemanager.FileStatusUploaded,
},
}
folderRepo := &fileManagerFolderRepoMock{
getByParentNameResult: &filemanager.Folder{
ID: rootID,
Name: filemanager.SystemRootFolderName,
NameNormalized: filemanager.SystemRootFolderName,
},
}
storage := &fileManagerStorageMock{taggingErr: errors.New("tagging denied")}
svc := NewFileManagerService(folderRepo, fileRepo, nil, FileManagerServiceDependencies{
EnableS3Tagging: true,
})
svc.SetObjectStorage(storage)
svc.SetFileProcessingQueue(&fileProcessingQueueMock{})
if _, err := svc.UploadFileNode(context.Background(), filemanager.UploadFileParams{
Name: "proof.pdf",
ContentType: "application/pdf",
SizeBytes: 10,
Body: strings.NewReader("0123456789"),
}); err != nil {
t.Fatalf("upload should still succeed when tagging fails, err=%v", err)
}
}