feat(svc): implement soft-delete in FileService
Delete now performs soft-delete (status='user_deleted') instead of physical deletion. Removes storage content deletion — files are preserved on disk after soft-delete. Second delete on already-deleted file returns nil (idempotent). Add tests: SoftDelete, SoftDeleteKeepsStorage, SoftDeleteIdempotent, SoftDeleteForbidden. Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -129,6 +129,7 @@ func (s *FileService) Upload(ctx context.Context, userID string, parentID *strin
|
||||
MimeType: mimeType,
|
||||
StoragePath: storagePath,
|
||||
Hash: hex.EncodeToString(hasher.Sum(nil)),
|
||||
Status: model.StatusActive,
|
||||
IsDir: false,
|
||||
}
|
||||
|
||||
@@ -240,10 +241,14 @@ func (s *FileService) Update(ctx context.Context, userID, fileID string, newName
|
||||
return modelToFileInfo(file), nil
|
||||
}
|
||||
|
||||
// Delete removes a file or (empty) directory.
|
||||
// Delete soft-deletes a file or (empty) directory. Storage content is preserved.
|
||||
func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||
file, err := s.getOwnedFile(ctx, userID, fileID)
|
||||
if err != nil {
|
||||
var ae *model.AppError
|
||||
if errors.As(err, &ae) && errors.Is(ae.Err, model.ErrNotFound) {
|
||||
return nil // idempotent: already deleted
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -262,14 +267,6 @@ func (s *FileService) Delete(ctx context.Context, userID, fileID string) error {
|
||||
return model.NewInternalError("delete file record", err)
|
||||
}
|
||||
|
||||
// Remove content from storage (directories have no stored content).
|
||||
if !file.IsDir {
|
||||
if err := s.storage.Delete(ctx, file.StoragePath); err != nil {
|
||||
s.logger.WarnContext(ctx, "failed to delete file from storage",
|
||||
"path", file.StoragePath, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -297,6 +294,7 @@ func (s *FileService) CreateDir(ctx context.Context, userID string, parentID *st
|
||||
UserID: userID,
|
||||
ParentID: parentID,
|
||||
Name: dirName,
|
||||
Status: model.StatusActive,
|
||||
IsDir: true,
|
||||
}
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ func (s *memStorage) Delete(_ context.Context, path string) error {
|
||||
|
||||
var _ storage.StorageBackend = (*memStorage)(nil)
|
||||
|
||||
func setupFileService(t *testing.T) *FileService {
|
||||
func setupFileServiceWithStorage(t *testing.T) (*FileService, *memStorage) {
|
||||
t.Helper()
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
@@ -87,7 +87,12 @@ func setupFileService(t *testing.T) *FileService {
|
||||
fileRepo := repository.NewFileRepository(db)
|
||||
store := newMemStorage()
|
||||
|
||||
return NewFileService(fileRepo, store, 0, nil) // 0 = unlimited, nil logger defaults to slog.Default()
|
||||
return NewFileService(fileRepo, store, 0, nil), store // 0 = unlimited, nil logger defaults to slog.Default()
|
||||
}
|
||||
|
||||
func setupFileService(t *testing.T) *FileService {
|
||||
svc, _ := setupFileServiceWithStorage(t)
|
||||
return svc
|
||||
}
|
||||
|
||||
func TestFileService_Upload(t *testing.T) {
|
||||
@@ -461,3 +466,80 @@ func TestFileService_VerifyParentForbidden(t *testing.T) {
|
||||
_, err = svc.Upload(ctx, "user2", &dir.ID, "stolen.txt", strings.NewReader("data"))
|
||||
assertAppError(t, err, http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestFileService_SoftDelete(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
info, err := svc.Upload(ctx, "user1", nil, "soft-delete.txt", strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload = %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
_, err = svc.Get(ctx, "user1", info.ID)
|
||||
assertAppError(t, err, http.StatusNotFound)
|
||||
}
|
||||
|
||||
func TestFileService_SoftDeleteKeepsStorage(t *testing.T) {
|
||||
svc, store := setupFileServiceWithStorage(t)
|
||||
ctx := context.Background()
|
||||
|
||||
content := "keep me after soft-delete"
|
||||
info, err := svc.Upload(ctx, "user1", nil, "keep.txt", strings.NewReader(content))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload = %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
||||
t.Fatalf("Delete = %v", err)
|
||||
}
|
||||
|
||||
storagePath := "user1/" + info.ID
|
||||
reader, err := store.Open(ctx, storagePath)
|
||||
if err != nil {
|
||||
t.Fatalf("storage should retain content after soft-delete, got: %v", err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read storage: %v", err)
|
||||
}
|
||||
if string(data) != content {
|
||||
t.Errorf("storage content = %q, want %q", string(data), content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_SoftDeleteIdempotent(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
info, err := svc.Upload(ctx, "user1", nil, "idem.txt", strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload = %v", err)
|
||||
}
|
||||
|
||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
||||
t.Fatalf("first Delete = %v", err)
|
||||
}
|
||||
if err := svc.Delete(ctx, "user1", info.ID); err != nil {
|
||||
t.Fatalf("second Delete should be idempotent, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileService_SoftDeleteForbidden(t *testing.T) {
|
||||
svc := setupFileService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
info, err := svc.Upload(ctx, "user1", nil, "forbidden.txt", strings.NewReader("data"))
|
||||
if err != nil {
|
||||
t.Fatalf("Upload = %v", err)
|
||||
}
|
||||
|
||||
err = svc.Delete(ctx, "user2", info.ID)
|
||||
assertAppError(t, err, http.StatusForbidden)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user