feat(workspace): trash for notes
Deleting a note (or its folder) moves it to the trash instead of erasing it. Trashed notes leave the tree, search, export and stats and are hidden from everyone they were shared with; the owner sees them read-only with a restore banner. New endpoints list, restore, purge and empty the trash; restoring recreates a deleted folder. Sidebar entry and mobile menu item with counter, list view with two-tap permanent delete. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
344f5e4ab2
commit
f69f74fdc7
15 files changed
+649
-40
No files matched your search
@@ -0,0 +1,93 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
func TestTrashHidesRestoresAndPurgesNotes(t *testing.T) {
|
||||
store, err := NewStore(filepath.Join(t.TempDir(), "trash.db"))
|
||||
if err != nil {
|
||||
t.Fatalf("NewStore: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
openSharing(t, store)
|
||||
anna, _ := store.CreateUser("anna@example.com", "hash", "Anna", "member")
|
||||
ben, _ := store.CreateUser("ben@example.com", "hash", "Ben", "member")
|
||||
blocks := []domain.DocumentBlock{{ID: "b1", Type: "bullet", Content: "Zebrastreifen", Level: 0}}
|
||||
doc, _ := store.CreateDocument(anna.ID, "", "Verkehr", "🚦", "Projekte/Stadt", blocks, nil, false)
|
||||
_, _ = store.CreateFolder(anna.ID, "Projekte/Stadt")
|
||||
if _, err := store.CreateDocShare(anna.ID, doc.ID, ben.ID, "write"); err != nil {
|
||||
t.Fatalf("share: %v", err)
|
||||
}
|
||||
|
||||
if err := store.DeleteDocument(anna.ID, doc.ID); err != nil {
|
||||
t.Fatalf("trash: %v", err)
|
||||
}
|
||||
visible := func(uid string) bool {
|
||||
docs, _ := store.GetDocuments(uid)
|
||||
for _, d := range docs {
|
||||
if d.ID == doc.ID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
if visible(anna.ID) || visible(ben.ID) {
|
||||
t.Errorf("trashed note must leave the tree of owner and shared users")
|
||||
}
|
||||
if res, _ := store.SearchGlobal(anna.ID, "Zebrastreifen"); len(res.Documents) != 0 {
|
||||
t.Errorf("trashed note must not be found: %+v", res.Documents)
|
||||
}
|
||||
if store.DocRole(anna.ID, doc.ID) != DocRoleViewer || store.DocRole(ben.ID, doc.ID) != "" {
|
||||
t.Errorf("trash is read-only for the owner and hidden from others")
|
||||
}
|
||||
if got, err := store.GetDocumentByID(anna.ID, doc.ID); err != nil || !got.IsArchived || got.Permission != "read" {
|
||||
t.Errorf("owner reads the trashed note read-only: %+v %v", got, err)
|
||||
}
|
||||
if _, err := store.GetDocumentByID(ben.ID, doc.ID); err == nil {
|
||||
t.Errorf("shared user must not open a trashed note")
|
||||
}
|
||||
title := "Neu"
|
||||
if _, err := store.UpdateDocument(anna.ID, doc.ID, &title, nil, nil, nil); err == nil {
|
||||
t.Errorf("trashed note must not be renamed")
|
||||
}
|
||||
trash, _ := store.GetTrash(anna.ID)
|
||||
if len(trash) != 1 || trash[0].FolderPath != "Projekte/Stadt" || trash[0].DeletedAt == "" {
|
||||
t.Fatalf("trash list wrong: %+v", trash)
|
||||
}
|
||||
if err := store.RestoreDocument(ben.ID, doc.ID); err == nil {
|
||||
t.Errorf("only the owner may restore")
|
||||
}
|
||||
|
||||
// The folder was deleted meanwhile; restoring creates it again
|
||||
_, _ = store.db.Exec(`DELETE FROM workspace_folders WHERE user_id = ?`, anna.ID)
|
||||
if err := store.RestoreDocument(anna.ID, doc.ID); err != nil {
|
||||
t.Fatalf("restore: %v", err)
|
||||
}
|
||||
if !visible(anna.ID) || !visible(ben.ID) || store.DocRole(ben.ID, doc.ID) != DocRoleEditor {
|
||||
t.Errorf("restored note is back for everyone, shares intact")
|
||||
}
|
||||
if folders, _ := store.GetFolders(anna.ID); len(folders) == 0 {
|
||||
t.Errorf("folder should be created again on restore")
|
||||
}
|
||||
|
||||
// Deleting a folder moves its notes to the trash; emptying deletes them for good
|
||||
if _, err := store.DeleteFolder(anna.ID, "Projekte"); err != nil {
|
||||
t.Fatalf("DeleteFolder: %v", err)
|
||||
}
|
||||
if trash, _ := store.GetTrash(anna.ID); len(trash) != 1 {
|
||||
t.Fatalf("folder deletion should trash the note, got %+v", trash)
|
||||
}
|
||||
if ids, err := store.EmptyTrash(anna.ID); err != nil || len(ids) != 1 {
|
||||
t.Fatalf("EmptyTrash: %v %v", ids, err)
|
||||
}
|
||||
var docs, shares int
|
||||
_ = store.db.QueryRow(`SELECT COUNT(*) FROM documents WHERE id = ?`, doc.ID).Scan(&docs)
|
||||
_ = store.db.QueryRow(`SELECT COUNT(*) FROM doc_shares WHERE doc_id = ?`, doc.ID).Scan(&shares)
|
||||
if docs+shares != 0 {
|
||||
t.Errorf("purge must remove the note and its shares")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user