From e04e39bea8a03e785a92a08187f7b6ca60c545c5 Mon Sep 17 00:00:00 2001 From: Huxley Date: Sat, 4 Jul 2026 17:20:34 +0800 Subject: [PATCH] feat(svc): implement soft-delete in FileService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- internal/service/file.go | 16 +++---- internal/service/file_test.go | 86 ++++++++++++++++++++++++++++++++++- 2 files changed, 91 insertions(+), 11 deletions(-) diff --git a/internal/service/file.go b/internal/service/file.go index 30b8198..8a720d4 100644 --- a/internal/service/file.go +++ b/internal/service/file.go @@ -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, } diff --git a/internal/service/file_test.go b/internal/service/file_test.go index be1f147..b14fc2a 100644 --- a/internal/service/file_test.go +++ b/internal/service/file_test.go @@ -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) +}