feature: move folders
This commit is contained in:
@@ -361,6 +361,219 @@ func TestSyncSourceDirHandles100FilesUnderTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFolderMovesAllDocumentsAndMetadata(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "guides", "deep"), 0o755); err != nil {
|
||||
t.Fatalf("create guides folder: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
|
||||
t.Fatalf("create intro.md: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "deep", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
|
||||
t.Fatalf("create dive.md: %v", err)
|
||||
}
|
||||
if _, err := service.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("SyncSourceDir() error = %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.repo.db.ExecContext(ctx, `
|
||||
INSERT INTO users (id, email, display_name, created_at) VALUES ('user-folder', 'folder@example.com', 'Folder', ?)
|
||||
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
if _, err := service.repo.db.ExecContext(ctx, `
|
||||
INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at)
|
||||
VALUES ('permission-folder', 'user-folder', 'folder', 'guides/deep', 'write', ?)
|
||||
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
t.Fatalf("create folder permission: %v", err)
|
||||
}
|
||||
if _, err := service.repo.db.ExecContext(ctx, `
|
||||
INSERT INTO watchers (user_id, document_id, folder_path, created_at)
|
||||
VALUES ('user-folder', NULL, 'guides', ?)
|
||||
`, time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
t.Fatalf("create folder watcher: %v", err)
|
||||
}
|
||||
|
||||
var changes []DocumentChange
|
||||
service.OnChange(func(change DocumentChange) {
|
||||
changes = append(changes, change)
|
||||
})
|
||||
|
||||
moved, err := service.MoveFolder(ctx, "guides", "handbook")
|
||||
if err != nil {
|
||||
t.Fatalf("MoveFolder() error = %v", err)
|
||||
}
|
||||
if moved != 2 {
|
||||
t.Fatalf("moved count = %d, want 2", moved)
|
||||
}
|
||||
for _, oldPath := range []string{"guides/intro.md", "guides/deep/dive.md"} {
|
||||
if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(oldPath))); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("old file %s still exists or stat failed: %v", oldPath, err)
|
||||
}
|
||||
}
|
||||
for oldPath, newPath := range map[string]string{"guides/intro.md": "handbook/intro.md", "guides/deep/dive.md": "handbook/deep/dive.md"} {
|
||||
if _, err := os.Stat(filepath.Join(sourceDir, filepath.FromSlash(newPath))); err != nil {
|
||||
t.Fatalf("new file %s missing: %v", newPath, err)
|
||||
}
|
||||
record, err := service.repo.GetDocumentByPath(ctx, newPath)
|
||||
if err != nil {
|
||||
t.Fatalf("get moved record %s: %v", newPath, err)
|
||||
}
|
||||
alias, err := service.repo.GetDocumentByPathOrAlias(ctx, oldPath)
|
||||
if err != nil {
|
||||
t.Fatalf("resolve alias for %s: %v", oldPath, err)
|
||||
}
|
||||
if alias.ID != record.ID {
|
||||
t.Fatalf("alias for %s resolved to %q, want %q", oldPath, alias.ID, record.ID)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourceDir, "guides")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("empty source folder not removed: %v", err)
|
||||
}
|
||||
|
||||
var permissionFolder string
|
||||
if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-folder'`).Scan(&permissionFolder); err != nil {
|
||||
t.Fatalf("get folder permission: %v", err)
|
||||
}
|
||||
if permissionFolder != "handbook/deep" {
|
||||
t.Fatalf("permission folder = %q, want handbook/deep", permissionFolder)
|
||||
}
|
||||
var watcherFolder string
|
||||
if err := service.repo.db.QueryRowContext(ctx, `SELECT folder_path FROM watchers WHERE user_id = 'user-folder'`).Scan(&watcherFolder); err != nil {
|
||||
t.Fatalf("get watcher: %v", err)
|
||||
}
|
||||
if watcherFolder != "handbook" {
|
||||
t.Fatalf("watcher folder = %q, want handbook", watcherFolder)
|
||||
}
|
||||
if len(changes) != 2 {
|
||||
t.Fatalf("move changes = %d, want 2", len(changes))
|
||||
}
|
||||
for _, change := range changes {
|
||||
if change.Type != "move" {
|
||||
t.Fatalf("change type = %q, want move", change.Type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoveFolderRejectsInvalidDestinations(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
|
||||
t.Fatalf("create guides folder: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
|
||||
t.Fatalf("create intro.md: %v", err)
|
||||
}
|
||||
if _, err := service.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("SyncSourceDir() error = %v", err)
|
||||
}
|
||||
|
||||
var conflict *DocumentMoveConflictError
|
||||
if _, err := service.MoveFolder(ctx, "guides", "guides"); !errors.As(err, &conflict) {
|
||||
t.Fatalf("unchanged MoveFolder() error = %v, want DocumentMoveConflictError", err)
|
||||
}
|
||||
if _, err := service.MoveFolder(ctx, "guides", "guides/nested"); !errors.As(err, &conflict) {
|
||||
t.Fatalf("self-nested MoveFolder() error = %v, want DocumentMoveConflictError", err)
|
||||
}
|
||||
if _, err := service.MoveFolder(ctx, "guides", "../outside"); err == nil {
|
||||
t.Fatal("expected traversal destination to be rejected")
|
||||
}
|
||||
if _, err := service.MoveFolder(ctx, "missing", "handbook"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("missing folder MoveFolder() error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
|
||||
t.Fatalf("source file changed after rejected moves: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveFolderArchivesAllDocuments(t *testing.T) {
|
||||
service, sourceDir := setupDocsTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(sourceDir, "guides"), 0o755); err != nil {
|
||||
t.Fatalf("create guides folder: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "intro.md"), []byte("# Intro\n"), 0o644); err != nil {
|
||||
t.Fatalf("create intro.md: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sourceDir, "guides", "dive.md"), []byte("# Dive\n"), 0o644); err != nil {
|
||||
t.Fatalf("create dive.md: %v", err)
|
||||
}
|
||||
if _, err := service.SyncSourceDir(ctx); err != nil {
|
||||
t.Fatalf("SyncSourceDir() error = %v", err)
|
||||
}
|
||||
|
||||
archived, err := service.ArchiveFolder(ctx, "guides")
|
||||
if err != nil {
|
||||
t.Fatalf("ArchiveFolder() error = %v", err)
|
||||
}
|
||||
if archived != 2 {
|
||||
t.Fatalf("archived count = %d, want 2", archived)
|
||||
}
|
||||
|
||||
records, err := service.repo.ListDocuments(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDocuments() error = %v", err)
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Path == "guides/intro.md" || record.Path == "guides/dive.md" {
|
||||
t.Fatalf("document %s still listed after archive", record.Path)
|
||||
}
|
||||
}
|
||||
archivedRecords, err := service.repo.ListArchivedDocuments(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("ListArchivedDocuments() error = %v", err)
|
||||
}
|
||||
if len(archivedRecords) != 2 {
|
||||
t.Fatalf("archived records = %d, want 2", len(archivedRecords))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(sourceDir, "guides", "intro.md")); err != nil {
|
||||
t.Fatalf("archived file removed from disk: %v", err)
|
||||
}
|
||||
|
||||
if _, err := service.ArchiveFolder(ctx, "guides"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("re-archive error = %v, want sql.ErrNoRows", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFolderPathValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "simple", path: "guides", want: "guides"},
|
||||
{name: "nested with slashes", path: "/guides/deep/", want: "guides/deep"},
|
||||
{name: "empty", path: "", wantErr: true},
|
||||
{name: "traversal", path: "../outside", wantErr: true},
|
||||
{name: "leading slash", path: "/etc", want: "etc"},
|
||||
{name: "markdown suffix", path: "guides.md", wantErr: true},
|
||||
{name: "nested markdown segment", path: "guides/notes.md/deep", wantErr: true},
|
||||
{name: "cleaned dot segment", path: "guides/./deep", want: "guides/deep"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NormalizeFolderPath(tt.path)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("NormalizeFolderPath(%q) = %q, want error", tt.path, got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeFolderPath(%q) error = %v", tt.path, err)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("NormalizeFolderPath(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupDocsTestService(t *testing.T) (*Service, string) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user