2621 lines
87 KiB
Go
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)
|
|
}
|
|
}
|