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