Merged in feature/add-deletes (pull request #214)
support delete for client, document and folder * support delete for client, document and folder * remove batch cancel conflict not used Approved-by: Jacob Mathison
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
package folder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/document"
|
||||
"queryorchestration/internal/harddelete"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// HardDelete permanently removes a folder tree and optionally all documents within it.
|
||||
// The root folder (path '/') cannot be deleted and returns a ConflictError.
|
||||
//
|
||||
// If includeDocuments is false and the folder tree contains documents, a ConflictError
|
||||
// is returned. The caller must either delete the documents first or set includeDocuments
|
||||
// to true.
|
||||
//
|
||||
// When includeDocuments is true, all documents in the folder tree are cascade-deleted
|
||||
// (using the same document cascade as document.HardDelete), then documentUploads and
|
||||
// folder rows are removed. The entire operation executes in a single database transaction.
|
||||
//
|
||||
// S3 source files are intentionally NOT deleted. Their paths are collected, logged at
|
||||
// INFO level, and returned for optional verbose responses.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: request context
|
||||
// - folderID: UUID of the root folder to delete
|
||||
// - includeDocuments: if true, cascade-delete all documents in the folder tree
|
||||
//
|
||||
// Returns:
|
||||
// - []harddelete.S3PathInfo: S3 paths of orphaned source documents (empty if no documents)
|
||||
// - error: NotFoundError, ConflictError, or database error
|
||||
func (s *Service) HardDelete(ctx context.Context, folderID uuid.UUID, includeDocuments bool) ([]harddelete.S3PathInfo, error) {
|
||||
queries := s.cfg.GetDBQueries()
|
||||
|
||||
// Verify the folder exists and is not the root folder
|
||||
if err := validateFolderForDelete(ctx, queries, folderID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Gather document info and S3 paths before the transaction
|
||||
docIDs, allS3Paths, err := collectFolderDocumentInfo(ctx, queries, folderID, includeDocuments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.cfg.ExecuteDBTransaction(ctx, func(ctx context.Context, q *repository.Queries) error {
|
||||
// Delete all documents in the folder tree if requested
|
||||
for _, docID := range docIDs {
|
||||
if err := document.DeleteDocumentCascade(ctx, q, docID); err != nil {
|
||||
return fmt.Errorf("cascade delete document %s: %w", docID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete documentUploads referencing folders in the tree
|
||||
if err := q.DeleteDocumentUploadsInFolderTree(ctx, &folderID); err != nil {
|
||||
return fmt.Errorf("delete document uploads in folder tree: %w", err)
|
||||
}
|
||||
|
||||
// Delete the folder tree
|
||||
rowsAffected, err := q.HardDeleteFolderTree(ctx, &folderID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete folder tree: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return &harddelete.NotFoundError{
|
||||
ResourceType: "folder",
|
||||
ResourceID: folderID.String(),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
harddelete.LogOrphanedS3Paths(allS3Paths)
|
||||
return allS3Paths, nil
|
||||
}
|
||||
|
||||
// validateFolderForDelete checks that a folder exists and is not the root folder.
|
||||
// Returns NotFoundError or ConflictError as appropriate.
|
||||
func validateFolderForDelete(ctx context.Context, queries *repository.Queries, folderID uuid.UUID) error {
|
||||
fdr, err := queries.GetFolderByID(ctx, folderID)
|
||||
if err != nil {
|
||||
return &harddelete.NotFoundError{
|
||||
ResourceType: "folder",
|
||||
ResourceID: folderID.String(),
|
||||
}
|
||||
}
|
||||
|
||||
if fdr.Path == "/" {
|
||||
return &harddelete.ConflictError{
|
||||
Message: "cannot delete root folder",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectFolderDocumentInfo checks for documents in the folder tree and collects their
|
||||
// S3 paths. Returns ConflictError if documents exist and includeDocuments is false.
|
||||
func collectFolderDocumentInfo(
|
||||
ctx context.Context,
|
||||
queries *repository.Queries,
|
||||
folderID uuid.UUID,
|
||||
includeDocuments bool,
|
||||
) ([]uuid.UUID, []harddelete.S3PathInfo, error) {
|
||||
docCount, err := queries.CountDocumentsInFolderTree(ctx, &folderID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to count documents in folder tree %s: %w", folderID, err)
|
||||
}
|
||||
|
||||
if docCount > 0 && !includeDocuments {
|
||||
return nil, nil, &harddelete.ConflictError{
|
||||
Message: fmt.Sprintf("folder tree contains %d documents; set include_documents=true to delete them", docCount),
|
||||
}
|
||||
}
|
||||
|
||||
if docCount == 0 {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
docIDs, err := queries.GetDocumentIDsInFolderTree(ctx, &folderID)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to get document IDs in folder tree %s: %w", folderID, err)
|
||||
}
|
||||
|
||||
allS3Paths, err := harddelete.CollectS3PathsForDocuments(ctx, docIDs, wrapS3Query(queries))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return docIDs, allS3Paths, nil
|
||||
}
|
||||
|
||||
// wrapS3Query adapts the repository's CollectDocumentS3Paths to the function signature
|
||||
// expected by harddelete.CollectS3PathsForDocuments.
|
||||
func wrapS3Query(queries *repository.Queries) func(ctx context.Context, docID uuid.UUID) ([]harddelete.S3Row, error) {
|
||||
return func(ctx context.Context, docID uuid.UUID) ([]harddelete.S3Row, error) {
|
||||
rows, err := queries.CollectDocumentS3Paths(ctx, docID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]harddelete.S3Row, len(rows))
|
||||
for i, r := range rows {
|
||||
result[i] = harddelete.S3Row{Bucket: r.Bucket, Key: r.Key}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package folder_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/folder"
|
||||
"queryorchestration/internal/harddelete"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// createDocWithEntry creates a document with an S3 entry in the given folder.
|
||||
// Returns the document ID.
|
||||
func createDocWithEntry(t *testing.T, cfg *TestConfig, clientID string, folderID *uuid.UUID, hash string) uuid.UUID {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
q := cfg.GetDBQueries()
|
||||
|
||||
filename := hash + ".pdf"
|
||||
docID, err := q.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: hash,
|
||||
Filename: &filename,
|
||||
Folderid: folderID,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
err = q.AddDocumentEntry(ctx, &repository.AddDocumentEntryParams{
|
||||
Documentid: docID,
|
||||
Bucket: "test-bucket",
|
||||
Key: "test-key/" + hash,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return docID
|
||||
}
|
||||
|
||||
func TestHardDeleteFolder(t *testing.T) {
|
||||
ctx := t.Context()
|
||||
cfg := &TestConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
|
||||
clientID := "test-folder-delete"
|
||||
test.CreateTestClient(t, cfg, clientID, "Test Folder Delete")
|
||||
|
||||
svc := folder.New(cfg)
|
||||
q := cfg.GetDBQueries()
|
||||
|
||||
// Get root folder for this client
|
||||
rootFolder, err := q.GetFolderByPath(ctx, &repository.GetFolderByPathParams{
|
||||
Clientid: clientID,
|
||||
Path: "/",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("rejects deleting root folder", func(t *testing.T) {
|
||||
_, err := svc.HardDelete(ctx, rootFolder.ID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var conflictErr *harddelete.ConflictError
|
||||
assert.True(t, errors.As(err, &conflictErr))
|
||||
assert.Contains(t, conflictErr.Message, "root folder")
|
||||
})
|
||||
|
||||
t.Run("returns NotFoundError for non-existent folder", func(t *testing.T) {
|
||||
fakeID := uuid.New()
|
||||
_, err := svc.HardDelete(ctx, fakeID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var notFoundErr *harddelete.NotFoundError
|
||||
assert.True(t, errors.As(err, ¬FoundErr))
|
||||
})
|
||||
|
||||
t.Run("deletes empty folder tree", func(t *testing.T) {
|
||||
parent, err := svc.CreateFolder(ctx, "/del-empty", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := svc.CreateFolder(ctx, "/del-empty/child", &parent.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
s3Paths, err := svc.HardDelete(ctx, parent.ID, false)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, s3Paths)
|
||||
|
||||
// Verify both folders are gone
|
||||
_, err = q.GetFolderByID(ctx, parent.ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetFolderByID(ctx, child.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("conflicts when folder has documents and include_documents is false", func(t *testing.T) {
|
||||
fdr, err := svc.CreateFolder(ctx, "/del-has-docs", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
createDocWithEntry(t, cfg, clientID, &fdr.ID, "folder-doc-conflict")
|
||||
|
||||
_, err = svc.HardDelete(ctx, fdr.ID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var conflictErr *harddelete.ConflictError
|
||||
assert.True(t, errors.As(err, &conflictErr))
|
||||
assert.Contains(t, conflictErr.Message, "documents")
|
||||
})
|
||||
|
||||
t.Run("deletes folder tree with documents when include_documents is true", func(t *testing.T) {
|
||||
parent, err := svc.CreateFolder(ctx, "/del-with-docs", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := svc.CreateFolder(ctx, "/del-with-docs/child", &parent.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
doc1ID := createDocWithEntry(t, cfg, clientID, &parent.ID, "parent-doc-1")
|
||||
doc2ID := createDocWithEntry(t, cfg, clientID, &child.ID, "child-doc-1")
|
||||
|
||||
s3Paths, err := svc.HardDelete(ctx, parent.ID, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify S3 paths returned for both documents
|
||||
assert.Len(t, s3Paths, 2)
|
||||
|
||||
// Verify both documents are gone
|
||||
_, err = q.GetDocumentSummary(ctx, doc1ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetDocumentSummary(ctx, doc2ID)
|
||||
require.Error(t, err)
|
||||
|
||||
// Verify folders are gone
|
||||
_, err = q.GetFolderByID(ctx, parent.ID)
|
||||
require.Error(t, err)
|
||||
_, err = q.GetFolderByID(ctx, child.ID)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("counts documents in nested folder tree correctly", func(t *testing.T) {
|
||||
parent, err := svc.CreateFolder(ctx, "/del-count", &rootFolder.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
child, err := svc.CreateFolder(ctx, "/del-count/sub", &parent.ID, clientID, "testuser")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Document in child, not parent
|
||||
createDocWithEntry(t, cfg, clientID, &child.ID, "nested-doc-count")
|
||||
|
||||
// Should conflict because the tree has a document (in the child)
|
||||
_, err = svc.HardDelete(ctx, parent.ID, false)
|
||||
require.Error(t, err)
|
||||
|
||||
var conflictErr *harddelete.ConflictError
|
||||
assert.True(t, errors.As(err, &conflictErr))
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user