verstak-desktop/internal/core/workspace/manager_test.go

376 lines
12 KiB
Go

package workspace
import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
func TestListWorkspacesReadsTopLevelPhysicalFolders(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
mustMkdir(t, filepath.Join(vaultDir, ".git"))
mustWrite(t, filepath.Join(vaultDir, "readme.md"), "not a workspace")
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
got := workspaceNames(workspaces)
want := []string{"Project", "Test"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("workspaces = %v, want %v", got, want)
}
}
func TestListWorkspacesExcludesTopLevelSymlink(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("symlink creation needs extra privileges on Windows")
}
vaultDir := newVaultDir(t)
target := filepath.Join(t.TempDir(), "outside")
mustMkdir(t, target)
if err := os.Symlink(target, filepath.Join(vaultDir, "Linked")); err != nil {
t.Fatalf("Symlink: %v", err)
}
m := NewManager(vaultDir)
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("expected symlink workspace to be excluded, got %+v", workspaces)
}
}
func TestLoadDoesNotCreateOrMigrateFoldersFromOldWorkspaceJSON(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, ".verstak"))
oldTree := `{"schemaVersion":1,"nodes":[{"id":"old","type":"space","title":"Old Tree Workspace","path":"Old Tree Workspace"}],"currentNodeId":"old"}`
mustWrite(t, filepath.Join(vaultDir, ".verstak", "workspace.json"), oldTree)
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Old Tree Workspace")); !os.IsNotExist(err) {
t.Fatalf("Load created folder from old workspace.json, stat err=%v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("workspace.json tree should not be source of truth, got %+v", workspaces)
}
}
func TestCreateWorkspaceCreatesFolderDefaultTemplateAndMetadataSnapshot(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
ws, err := m.CreateWorkspace("Project", "")
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if ws.Name != "Project" || ws.RootPath != "Project" {
t.Fatalf("workspace = %+v, want Project root", ws)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); err != nil {
t.Fatalf("workspace folder missing: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project", "Notes", "Overview.md")); err != nil {
t.Fatalf("default template overview missing: %v", err)
}
meta, err := m.GetWorkspaceMetadata("Project")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if meta.WorkspaceName != "Project" {
t.Fatalf("metadata workspaceName = %q", meta.WorkspaceName)
}
if meta.CreatedFromTemplate == nil {
t.Fatal("metadata missing createdFromTemplate snapshot")
}
if meta.CreatedFromTemplate.TemplateID != "default" || meta.CreatedFromTemplate.TemplateName == "" || meta.CreatedFromTemplate.TemplateVersion == 0 || meta.CreatedFromTemplate.AppliedAt == "" {
t.Fatalf("bad template snapshot: %+v", meta.CreatedFromTemplate)
}
if !meta.Features["files"] || !meta.Features["notes"] {
t.Fatalf("features = %+v, want files and notes enabled", meta.Features)
}
if meta.Folders["notes"] != "Notes" {
t.Fatalf("folders = %+v, want notes folder", meta.Folders)
}
}
func TestWorkspaceMetadataDoesNotRequireLiveTemplate(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("ClientA", "client-project"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
ClearTemplateRegistryForTest(t)
meta, err := m.GetWorkspaceMetadata("ClientA")
if err != nil {
t.Fatalf("GetWorkspaceMetadata after registry clear: %v", err)
}
if meta.CreatedFromTemplate == nil || meta.CreatedFromTemplate.TemplateID != "client-project" {
t.Fatalf("snapshot not preserved after registry clear: %+v", meta.CreatedFromTemplate)
}
}
func TestMissingMetadataReturnsGenericWorkspaceMetadata(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Loose"))
m := NewManager(vaultDir)
meta, err := m.GetWorkspaceMetadata("Loose")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if meta.WorkspaceName != "Loose" {
t.Fatalf("workspaceName = %q", meta.WorkspaceName)
}
if meta.CreatedFromTemplate != nil {
t.Fatalf("generic metadata should not invent a template snapshot: %+v", meta.CreatedFromTemplate)
}
if !meta.Features["files"] {
t.Fatalf("generic metadata should enable files at minimum: %+v", meta.Features)
}
}
func TestGetWorkspaceMetadataReturnsCanonicalFolderNameWhenStoredNameIsStale(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
data, err := os.ReadFile(m.metadataPath("Project"))
if err != nil {
t.Fatalf("read metadata: %v", err)
}
var meta Metadata
if err := json.Unmarshal(data, &meta); err != nil {
t.Fatalf("unmarshal metadata: %v", err)
}
meta.WorkspaceName = "OldName"
staleData, err := json.MarshalIndent(meta, "", " ")
if err != nil {
t.Fatalf("marshal metadata: %v", err)
}
if err := os.WriteFile(m.metadataPath("Project"), staleData, 0o600); err != nil {
t.Fatalf("write stale metadata: %v", err)
}
got, err := m.GetWorkspaceMetadata("Project")
if err != nil {
t.Fatalf("GetWorkspaceMetadata: %v", err)
}
if got.WorkspaceName != "Project" {
t.Fatalf("workspaceName = %q, want canonical folder name Project", got.WorkspaceName)
}
}
func TestRenameWorkspacePhysicallyRenamesFolderAndMetadata(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
if err := m.RenameWorkspace("Project", "Renamed"); err != nil {
t.Fatalf("RenameWorkspace: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
t.Fatalf("old folder still exists or stat failed unexpectedly: %v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Renamed")); err != nil {
t.Fatalf("renamed folder missing: %v", err)
}
meta, err := m.GetWorkspaceMetadata("Renamed")
if err != nil {
t.Fatalf("metadata after rename: %v", err)
}
if meta.WorkspaceName != "Renamed" {
t.Fatalf("metadata workspaceName = %q, want Renamed", meta.WorkspaceName)
}
if _, err := os.Stat(m.metadataPath("Project")); !os.IsNotExist(err) {
t.Fatalf("old metadata key still exists or stat failed unexpectedly: %v", err)
}
}
func TestTrashWorkspaceMovesFolderToTrashAndRemovesFromList(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
result, err := m.TrashWorkspace("Project")
if err != nil {
t.Fatalf("TrashWorkspace: %v", err)
}
if result.OriginalPath != "Project" || result.TrashID == "" || result.TrashPath == "" {
t.Fatalf("bad trash result: %+v", result)
}
if _, err := os.Stat(filepath.Join(vaultDir, "Project")); !os.IsNotExist(err) {
t.Fatalf("workspace still exists after trash, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(vaultDir, filepath.FromSlash(result.TrashPath))); err != nil {
t.Fatalf("trashed workspace missing: %v", err)
}
workspaces, err := m.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
if len(workspaces) != 0 {
t.Fatalf("workspace should be removed from list after trash, got %+v", workspaces)
}
}
func TestCreateAndRenameConflictsAreExplicit(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Existing"))
mustMkdir(t, filepath.Join(vaultDir, "Other"))
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Existing", ""); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("create conflict error = %v, want conflict", err)
}
if err := m.RenameWorkspace("Existing", "Other"); err == nil || !strings.Contains(err.Error(), "conflict") {
t.Fatalf("rename conflict error = %v, want conflict", err)
}
}
func TestInvalidWorkspaceNamesRejected(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
names := []string{"", " ", "A/B", `A\B`, "/abs", `C:\abs`, "..", "a..b", "bad\x00name", ".verstak", ".Verstak", ".git"}
for _, name := range names {
if _, err := m.CreateWorkspace(name, ""); err == nil {
t.Fatalf("CreateWorkspace(%q) succeeded, want invalid name error", name)
}
}
}
func TestCompatibilityTreeIsDerivedFromTopLevelFolders(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Project", "Nested"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
tree := m.GetTree()
if len(tree.Nodes) != 2 {
t.Fatalf("nodes = %+v, want 2 top-level workspaces", tree.Nodes)
}
if tree.Nodes[0].ID != "Project" || tree.Nodes[0].Title != "Project" || tree.Nodes[0].Path != "" {
t.Fatalf("first compatibility node = %+v, want derived workspace without persisted path mapping", tree.Nodes[0])
}
for _, node := range tree.Nodes {
if node.ParentID != "" {
t.Fatalf("compatibility tree should be flat, got child node %+v", node)
}
if node.ID == "Nested" || node.Title == "Nested" {
t.Fatalf("nested folders must not become workspace nodes: %+v", tree.Nodes)
}
}
}
func TestMoveNodeCompatibilityDoesNotCreateNestedWorkspaceModel(t *testing.T) {
vaultDir := newVaultDir(t)
mustMkdir(t, filepath.Join(vaultDir, "Project"))
mustMkdir(t, filepath.Join(vaultDir, "Test"))
m := NewManager(vaultDir)
if err := m.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
err := m.MoveNode("Project", "Test")
if err == nil || !strings.Contains(err.Error(), "top-level only") {
t.Fatalf("MoveNode error = %v, want top-level only", err)
}
if _, statErr := os.Stat(filepath.Join(vaultDir, "Test", "Project")); !os.IsNotExist(statErr) {
t.Fatalf("MoveNode created nested mapped workspace, stat err=%v", statErr)
}
}
func TestMetadataFileShape(t *testing.T) {
vaultDir := newVaultDir(t)
m := NewManager(vaultDir)
if _, err := m.CreateWorkspace("Project", "default"); err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
data, err := os.ReadFile(m.metadataPath("Project"))
if err != nil {
t.Fatalf("read metadata: %v", err)
}
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("metadata JSON: %v", err)
}
if raw["workspaceName"] != "Project" {
t.Fatalf("workspaceName = %v", raw["workspaceName"])
}
if _, ok := raw["createdFromTemplate"].(map[string]interface{}); !ok {
t.Fatalf("createdFromTemplate missing in raw metadata: %s", data)
}
}
func newVaultDir(t *testing.T) string {
t.Helper()
vaultDir := filepath.Join(t.TempDir(), "vault")
mustMkdir(t, vaultDir)
mustMkdir(t, filepath.Join(vaultDir, ".verstak", "trash"))
return vaultDir
}
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", path, err)
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("MkdirAll(%s): %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("WriteFile(%s): %v", path, err)
}
}
func workspaceNames(workspaces []Workspace) []string {
names := make([]string, len(workspaces))
for i, ws := range workspaces {
names[i] = ws.Name
}
return names
}