From 9496796da12afd65b200f1f2a1808e65b813e195 Mon Sep 17 00:00:00 2001 From: Patrick Erber Date: Mon, 9 Mar 2026 21:28:33 +0100 Subject: [PATCH 1/7] chore: remove unused code --- internal/core/tree/tree_service.go | 42 ------------------------------ 1 file changed, 42 deletions(-) diff --git a/internal/core/tree/tree_service.go b/internal/core/tree/tree_service.go index d282fb91..20cf382a 100644 --- a/internal/core/tree/tree_service.go +++ b/internal/core/tree/tree_service.go @@ -1095,39 +1095,6 @@ func (t *TreeService) SortPages(parentID string, orderedIDs []string) error { return t.saveTreeLocked() } -// maybeCollapseSectionToPageLocked tries to collapse a section node into a page node -// It is not used currently, but after testing the user flow we might want to integrate it -// into UpdateNode or MoveNode operations -// Lock must be held by the caller -// func (t *TreeService) maybeCollapseSectionToPageLocked(node *PageNode) { -// if node == nil || node.ID == "root" { -// return -// } -// if node.Kind != NodeKindSection { -// return -// } -// if node.HasChildren() { -// return -// } - -// // Only collapse if index.md exists -// indexPath, err := t.store.contentPathForNodeRead(node) -// if err != nil { -// return -// } -// if _, err := os.Stat(indexPath); err != nil { -// // no index.md => keep as section -// return -// } - -// // Try collapse (will refuse if folder has other files) -// if err := t.store.ConvertNode(node, NodeKindPage); err != nil { -// // not allowed (e.g. folder not empty) -> keep section -// return -// } -// node.Kind = NodeKindPage -// } - func (t *TreeService) reindexPositions(parent *PageNode) { sort.SliceStable(parent.Children, func(i, j int) bool { return parent.Children[i].Position < parent.Children[j].Position @@ -1136,12 +1103,3 @@ func (t *TreeService) reindexPositions(parent *PageNode) { child.Position = i } } - -// func (t *TreeService) sortTreeByPosition(node *PageNode) { -// sort.SliceStable(node.Children, func(i, j int) bool { -// return node.Children[i].Position < node.Children[j].Position -// }) -// for _, child := range node.Children { -// t.sortTreeByPosition(child) -// } -// } From d7f44a4f3a1e02af1866bdf44dad749498924e5a Mon Sep 17 00:00:00 2001 From: Patrick Erber Date: Mon, 9 Mar 2026 21:29:17 +0100 Subject: [PATCH 2/7] feat: enrich frontmatter & markdown --- internal/core/markdown/frontmatter.go | 80 +++++++++++++++------------ internal/core/markdown/markdown.go | 17 +++++- 2 files changed, 62 insertions(+), 35 deletions(-) diff --git a/internal/core/markdown/frontmatter.go b/internal/core/markdown/frontmatter.go index 80b29639..84498414 100644 --- a/internal/core/markdown/frontmatter.go +++ b/internal/core/markdown/frontmatter.go @@ -18,6 +18,34 @@ func invalidYAMLKeyRune(r rune) bool { type Frontmatter struct { LeafWikiID string `yaml:"leafwiki_id,omitempty" json:"id,omitempty"` LeafWikiTitle string `yaml:"leafwiki_title,omitempty" json:"title,omitempty"` + CreatedAt string `yaml:"created_at,omitempty" json:"created_at,omitempty"` + CreatorID string `yaml:"creator_id,omitempty" json:"creator_id,omitempty"` + UpdatedAt string `yaml:"updated_at,omitempty" json:"updated_at,omitempty"` + LastAuthorID string `yaml:"last_author_id,omitempty" json:"last_author_id,omitempty"` +} + +func (fm *Frontmatter) stripSingleAndDoubleQuotes(s string) string { + s = strings.Trim(s, `"`) + s = strings.Trim(s, `'`) + return s +} + +func (fm *Frontmatter) Normalize() { + fm.LeafWikiID = fm.stripSingleAndDoubleQuotes(strings.TrimSpace(fm.LeafWikiID)) + fm.LeafWikiTitle = fm.stripSingleAndDoubleQuotes(strings.TrimSpace(fm.LeafWikiTitle)) + fm.CreatedAt = fm.stripSingleAndDoubleQuotes(strings.TrimSpace(fm.CreatedAt)) + fm.CreatorID = fm.stripSingleAndDoubleQuotes(strings.TrimSpace(fm.CreatorID)) + fm.UpdatedAt = fm.stripSingleAndDoubleQuotes(strings.TrimSpace(fm.UpdatedAt)) + fm.LastAuthorID = fm.stripSingleAndDoubleQuotes(strings.TrimSpace(fm.LastAuthorID)) +} + +func (fm Frontmatter) IsZero() bool { + return fm.LeafWikiID == "" && + fm.LeafWikiTitle == "" && + fm.CreatedAt == "" && + fm.CreatorID == "" && + fm.UpdatedAt == "" && + fm.LastAuthorID == "" } func (fm *Frontmatter) LoadFrontMatterFromContent(yamlPart string) (has bool, err error) { @@ -25,29 +53,20 @@ func (fm *Frontmatter) LoadFrontMatterFromContent(yamlPart string) (has bool, er return true, errors.Join(ErrFrontmatterParse, err) } - /** Check for title also in frontmatter **/ type titleOnlyStruct struct { Title string `yaml:"title,omitempty"` } var tos titleOnlyStruct if err := yaml.Unmarshal([]byte(yamlPart), &tos); err == nil { - if tos.Title != "" { + if tos.Title != "" && fm.LeafWikiTitle == "" { fm.LeafWikiTitle = tos.Title } } - fm.LeafWikiID = fm.stripSingleAndDoubleQuotes(fm.LeafWikiID) - fm.LeafWikiTitle = fm.stripSingleAndDoubleQuotes(fm.LeafWikiTitle) - + fm.Normalize() return true, nil } -func (fm *Frontmatter) stripSingleAndDoubleQuotes(s string) string { - s = strings.Trim(s, `"`) - s = strings.Trim(s, `'`) - return s -} - func (fm *Frontmatter) LoadFrontMatterFromFile(mdFilePath string) (has bool, err error) { content, err := os.ReadFile(mdFilePath) if err != nil { @@ -57,43 +76,34 @@ func (fm *Frontmatter) LoadFrontMatterFromFile(mdFilePath string) (has bool, err } func splitFrontmatter(md string) (yamlPart string, body string, has bool) { - // BOM-safe + normalize newlines s := strings.TrimPrefix(md, "\ufeff") s = strings.ReplaceAll(s, "\r\n", "\n") s = strings.ReplaceAll(s, "\r", "\n") - // Must start with '---' on the very first line if s != "---" && !strings.HasPrefix(s, "---\n") { return "", md, false } - // Find end of first line firstNL := strings.IndexByte(s, '\n') if firstNL == -1 { - // it's exactly "---" (or a single-line file) return "", md, false } if strings.TrimSpace(s[:firstNL]) != "---" { return "", md, false } - // Find closing delimiter on its own line: "\n---\n" or "\n---" at EOF - // We'll scan line-by-line using indices. pos := firstNL + 1 yamlStart := pos endDelimLineStart := -1 endDelimLineEnd := -1 - looksLikeYAML := false for pos <= len(s) { - // find end of current line nextNL := strings.IndexByte(s[pos:], '\n') var line string var lineEnd int if nextNL == -1 { - // last line lineEnd = len(s) line = s[pos:lineEnd] } else { @@ -108,8 +118,6 @@ func splitFrontmatter(md string) (yamlPart string, body string, has bool) { break } - // Heuristic: at least one "key:" line => treat as YAML frontmatter - // Skip blanks/comments if trim != "" && !strings.HasPrefix(trim, "#") { if idx := strings.IndexByte(trim, ':'); idx > 0 { key := strings.TrimSpace(trim[:idx]) @@ -119,7 +127,6 @@ func splitFrontmatter(md string) (yamlPart string, body string, has bool) { } } - // advance to next line if nextNL == -1 { pos = len(s) + 1 } else { @@ -127,21 +134,16 @@ func splitFrontmatter(md string) (yamlPart string, body string, has bool) { } } - // No closing delimiter found => treat as no frontmatter if endDelimLineStart == -1 { return "", md, false } - - // If it doesn't look like YAML, treat as plain markdown (separator use-case) if !looksLikeYAML { return "", md, false } - // YAML is between yamlStart and the start of the closing delimiter line yamlPart = s[yamlStart:endDelimLineStart] - yamlPart = strings.TrimSuffix(yamlPart, "\n") // nice-to-have + yamlPart = strings.TrimSuffix(yamlPart, "\n") - // Body starts after the closing delimiter line (+ its trailing newline if present) bodyStart := endDelimLineEnd if bodyStart < len(s) && s[bodyStart:bodyStart+1] == "\n" { bodyStart++ @@ -161,14 +163,24 @@ func ParseFrontmatter(md string) (fm Frontmatter, body string, has bool, err err return Frontmatter{}, md, true, errors.Join(ErrFrontmatterParse, err) } - fm.LeafWikiID = fm.stripSingleAndDoubleQuotes(fm.LeafWikiID) - fm.LeafWikiTitle = fm.stripSingleAndDoubleQuotes(fm.LeafWikiTitle) + type titleOnlyStruct struct { + Title string `yaml:"title,omitempty"` + } + var tos titleOnlyStruct + if err := yaml.Unmarshal([]byte(yamlPart), &tos); err == nil { + if tos.Title != "" && fm.LeafWikiTitle == "" { + fm.LeafWikiTitle = tos.Title + } + } + + fm.Normalize() return fm, body, true, nil } func BuildMarkdownWithFrontmatter(fm Frontmatter, body string) (string, error) { - // Avoid emitting empty frontmatter like "{}" - if strings.TrimSpace(fm.LeafWikiID) == "" { + fm.Normalize() + + if fm.IsZero() { return body, nil } @@ -179,7 +191,7 @@ func BuildMarkdownWithFrontmatter(fm Frontmatter, body string) (string, error) { var out bytes.Buffer out.WriteString("---\n") - out.Write(b) // yaml.v3 usually ends with \n, which is fine + out.Write(b) out.WriteString("---\n") out.WriteString(body) return out.String(), nil diff --git a/internal/core/markdown/markdown.go b/internal/core/markdown/markdown.go index d34fde03..2a073047 100644 --- a/internal/core/markdown/markdown.go +++ b/internal/core/markdown/markdown.go @@ -110,5 +110,20 @@ func (mf *MarkdownFile) GetFrontmatter() Frontmatter { } func (mf *MarkdownFile) SetFrontmatterID(id string) { - mf.fm.LeafWikiID = id + mf.fm.LeafWikiID = strings.TrimSpace(id) +} + +func (mf *MarkdownFile) SetFrontmatterTitle(title string) { + mf.fm.LeafWikiTitle = strings.TrimSpace(title) +} + +func (mf *MarkdownFile) SetFrontmatterMetadata(createdAt, creatorID, updatedAt, lastAuthorID string) { + mf.fm.CreatedAt = strings.TrimSpace(createdAt) + mf.fm.CreatorID = strings.TrimSpace(creatorID) + mf.fm.UpdatedAt = strings.TrimSpace(updatedAt) + mf.fm.LastAuthorID = strings.TrimSpace(lastAuthorID) +} + +func (mf *MarkdownFile) SetContent(content string) { + mf.content = content } From 504670b8fafa686bc60bf2e9fdfc05c84d18769a Mon Sep 17 00:00:00 2001 From: Patrick Erber Date: Sun, 15 Mar 2026 16:15:50 +0100 Subject: [PATCH 3/7] wip: refactor tree service and node store to fs --- internal/core/tree/helpers.go | 8 + internal/core/tree/legacy_tree.go | 20 + internal/core/tree/node_store.go | 828 +++++++++++++----- .../core/tree/node_store_reconstruct_test.go | 3 +- internal/core/tree/page_node.go | 81 +- internal/core/tree/tree_service.go | 508 ++--------- internal/http/api/create_page.go | 2 +- internal/http/api/helpers.go | 36 +- internal/http/api/node.go | 12 +- internal/http/router_test.go | 56 +- internal/links/helpers.go | 4 +- internal/wiki/wiki.go | 39 +- 12 files changed, 839 insertions(+), 758 deletions(-) create mode 100644 internal/core/tree/legacy_tree.go diff --git a/internal/core/tree/helpers.go b/internal/core/tree/helpers.go index 618ed645..a94c594a 100644 --- a/internal/core/tree/helpers.go +++ b/internal/core/tree/helpers.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path" + "time" ) func GeneratePathFromPageNode(entry *PageNode) string { @@ -77,3 +78,10 @@ func FoldPageFolderIfEmpty(storageDir string, pagePath string) error { return nil } + +func formatRFC3339(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} diff --git a/internal/core/tree/legacy_tree.go b/internal/core/tree/legacy_tree.go new file mode 100644 index 00000000..48793b2b --- /dev/null +++ b/internal/core/tree/legacy_tree.go @@ -0,0 +1,20 @@ +package tree + +import "time" + +type legacyPageMetadata struct { + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + CreatorID string `json:"creatorId"` + LastAuthorID string `json:"lastAuthorId"` +} + +type legacyPageNode struct { + ID string `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Children []*legacyPageNode `json:"children"` + Position int `json:"position"` + Kind NodeKind `json:"kind"` + Metadata legacyPageMetadata `json:"metadata"` +} diff --git a/internal/core/tree/node_store.go b/internal/core/tree/node_store.go index ff23a6ca..79d0eadf 100644 --- a/internal/core/tree/node_store.go +++ b/internal/core/tree/node_store.go @@ -4,35 +4,36 @@ import ( "encoding/json" "errors" "fmt" - "io" "log/slog" "os" "path/filepath" "sort" "strings" + "time" "github.com/perber/wiki/internal/core/markdown" "github.com/perber/wiki/internal/core/shared" ) +const legacyTreeFilename = "tree.json" +const migratedLegacyTreeFilename = "tree.json.migrated.bak" +const fsMigrationMarker = ".leafwiki_fs_migrated" + func fileExists(p string) bool { _, err := os.Stat(p) return err == nil } -type ResolvedNode struct { - Kind NodeKind - DirPath string - FilePath string - HasContent bool -} - type NodeStore struct { storageDir string log *slog.Logger slugger *SlugService } +type sectionOrderFile struct { + Children []string `json:"children"` +} + func NewNodeStore(storageDir string) *NodeStore { return &NodeStore{ storageDir: storageDir, @@ -41,58 +42,69 @@ func NewNodeStore(storageDir string) *NodeStore { } } -// writeIDToMarkdownFile writes a leafwiki_id to a markdown file's frontmatter and logs errors if the write fails -func (f *NodeStore) writeIDToMarkdownFile(mdFile *markdown.MarkdownFile, id string) { - mdFile.SetFrontmatterID(id) - if err := mdFile.WriteToFile(); err != nil { - f.log.Error("could not write leafwiki_id back to file", "path", mdFile.GetPath(), "error", err) - } +func (f *NodeStore) migratedLegacyTreePath() string { + return filepath.Join(f.storageDir, migratedLegacyTreeFilename) } -func (f *NodeStore) LoadTree(filename string) (*PageNode, error) { - fullPath := filepath.Join(f.storageDir, filename) +func (f *NodeStore) legacyTreePath() string { + return filepath.Join(f.storageDir, legacyTreeFilename) +} - // check if file exists - if _, err := os.Stat(fullPath); os.IsNotExist(err) { - return &PageNode{ - ID: "root", - Slug: "root", - Title: "root", - Parent: nil, - Position: 0, - Children: []*PageNode{}, - Kind: NodeKindSection, - }, nil - } +func (f *NodeStore) migrationMarkerPath() string { + return filepath.Join(f.storageDir, fsMigrationMarker) +} - file, err := os.Open(fullPath) - if err != nil { - return nil, fmt.Errorf("open tree file %s: %w", fullPath, err) - } - defer func() { - if err := file.Close(); err != nil { - f.log.Error("could not close tree file", "file", fullPath, "error", err) - } - }() - data, err := io.ReadAll(file) +func (f *NodeStore) HasLegacyTreeJSON() bool { + return fileExists(f.legacyTreePath()) +} - if err != nil { - return nil, fmt.Errorf("read tree file %s: %w", fullPath, err) +func (f *NodeStore) HasFSMigrationMarker() bool { + return fileExists(f.migrationMarkerPath()) +} + +func (f *NodeStore) WriteFSMigrationMarker() error { + return os.WriteFile(f.migrationMarkerPath(), []byte("ok\n"), 0o644) +} + +func (f *NodeStore) ArchiveLegacyTreeJSON() error { + src := f.legacyTreePath() + dst := f.migratedLegacyTreePath() + + if !fileExists(src) { + return nil + } + if fileExists(dst) { + return fmt.Errorf("cannot archive legacy tree.json: destination already exists: %s", dst) } + if err := os.Rename(src, dst); err != nil { + return fmt.Errorf("archive legacy tree.json: %w", err) + } + + return nil +} - tree := &PageNode{} - if err := json.Unmarshal(data, tree); err != nil { - return nil, fmt.Errorf("unmarshal tree data %s: %w", fullPath, err) +func (f *NodeStore) LoadLegacyTree() (*legacyPageNode, error) { + path := f.legacyTreePath() + raw, err := os.ReadFile(path) + if err != nil { + return nil, err } - if tree.ID == "root" && tree.Kind == "" { - tree.Kind = NodeKindSection + var root legacyPageNode + if err := json.Unmarshal(raw, &root); err != nil { + return nil, fmt.Errorf("unmarshal legacy tree.json: %w", err) } - // assigns parent to children - f.assignParentToChildren(tree) + return &root, nil +} - return tree, nil +func (f *NodeStore) RootDirHasContent() bool { + rootDir := filepath.Join(f.storageDir, "root") + entries, err := os.ReadDir(rootDir) + if err != nil { + return false + } + return len(entries) > 0 } func (f *NodeStore) ReconstructTreeFromFS() (*PageNode, error) { @@ -101,7 +113,6 @@ func (f *NodeStore) ReconstructTreeFromFS() (*PageNode, error) { Slug: "root", Title: "root", Parent: nil, - Position: 0, Children: []*PageNode{}, Kind: NodeKindSection, } @@ -125,8 +136,11 @@ func (f *NodeStore) ReconstructTreeFromFS() (*PageNode, error) { return nil, fmt.Errorf("reconstruct tree from fs: %w", err) } + f.markDuplicateIDs(root) + return root, nil } + func (f *NodeStore) reconstructTreeRecursive(currentPath string, parent *PageNode) error { entries, err := os.ReadDir(currentPath) if err != nil { @@ -143,23 +157,24 @@ func (f *NodeStore) reconstructTreeRecursive(currentPath string, parent *PageNod return li < lj }) + var children []*PageNode for _, entry := range entries { name := entry.Name() - // optional: skip hidden stuff + // ignore hidden files and folders (those starting with .) if strings.HasPrefix(name, ".") { continue } - // defaults - title := name - id, err := shared.GenerateUniqueID() - if err != nil { - return fmt.Errorf("generate unique ID: %w", err) + if strings.EqualFold(name, "order.json") { + continue } + title := name + id := "" + repairNeeded := false + var issues []NodeIssue if entry.IsDir() { - // Normalize and validate the directory name as a slug normalizedSlug := normalizeSlug(name) if err := f.slugger.IsValidSlug(normalizedSlug); err != nil { f.log.Error("skipping directory with invalid slug", "directory", name, "normalized", normalizedSlug, "error", err) @@ -171,32 +186,48 @@ func (f *NodeStore) reconstructTreeRecursive(currentPath string, parent *PageNod mdFile, err := markdown.LoadMarkdownFile(indexPath) if err != nil { f.log.Error("could not load index.md", "path", indexPath, "error", err) - // fall back to default title and generated ID, but still add the section and recurse + repairNeeded = true + issues = append(issues, NodeIssue{ + Code: NodeIssueMissingIndexMD, + Message: "index.md exists but could not be parsed", + }) } else { title, err = mdFile.GetTitle() if err != nil { f.log.Error("could not extract title from index.md", "path", indexPath, "error", err) - // keep default title; still add the section and recurse } if mdFile.GetFrontmatter().LeafWikiID != "" { id = mdFile.GetFrontmatter().LeafWikiID - } else { - // Generated ID needs to be written back - f.writeIDToMarkdownFile(mdFile, id) } } + } else { + repairNeeded = true + issues = append(issues, NodeIssue{ + Code: NodeIssueMissingIndexMD, + Message: "section has no index.md", + }) + } + + if strings.TrimSpace(id) == "" { + id = syntheticNodeID(filepath.Join(currentPath, name)) + repairNeeded = true + issues = append(issues, NodeIssue{ + Code: NodeIssueMissingID, + Message: "section is missing leafwiki_id in index.md frontmatter", + }) } child := &PageNode{ - ID: id, - Slug: normalizedSlug, - Title: title, - Parent: parent, - Position: len(parent.Children), - Children: []*PageNode{}, - Kind: NodeKindSection, + ID: id, + Slug: normalizedSlug, + Title: title, + Parent: parent, + Children: []*PageNode{}, + Kind: NodeKindSection, + RepairNeeded: repairNeeded, + Issues: issues, } - parent.Children = append(parent.Children, child) + children = append(children, child) if err := f.reconstructTreeRecursive(filepath.Join(currentPath, name), child); err != nil { return err @@ -210,9 +241,7 @@ func (f *NodeStore) reconstructTreeRecursive(currentPath string, parent *PageNod continue } - // Normalize and validate the filename (without .md) as a slug baseFilename := strings.TrimSuffix(name, ext) - // skip index.md (handled by section case) if strings.EqualFold(baseFilename, "index") { continue } @@ -237,51 +266,487 @@ func (f *NodeStore) reconstructTreeRecursive(currentPath string, parent *PageNod if mdFile.GetFrontmatter().LeafWikiID != "" { id = mdFile.GetFrontmatter().LeafWikiID } else { - // Generated ID needs to be written back - f.writeIDToMarkdownFile(mdFile, id) + id = syntheticNodeID(filePath) + repairNeeded = true + issues = append(issues, NodeIssue{ + Code: NodeIssueMissingID, + Message: "page is missing leafwiki_id in frontmatter", + }) } child := &PageNode{ - ID: id, - Slug: normalizedSlug, - Title: title, - Parent: parent, - Position: len(parent.Children), - Children: nil, - Kind: NodeKindPage, + ID: id, + Slug: normalizedSlug, + Title: title, + Parent: parent, + Children: nil, + Kind: NodeKindPage, + RepairNeeded: repairNeeded, + Issues: issues, + } + children = append(children, child) + } + + parent.Children = f.sortChildrenForParent(currentPath, children, parent) + return nil +} + +func (f *NodeStore) MigrateLegacyTreeJSONToFS() error { + if !f.HasLegacyTreeJSON() { + return nil + } + if f.HasFSMigrationMarker() { + return nil + } + if f.RootDirHasContent() { + return fmt.Errorf("refusing legacy migration: root/ already contains content") + } + + legacyRoot, err := f.LoadLegacyTree() + if err != nil { + return fmt.Errorf("load legacy tree: %w", err) + } + if legacyRoot == nil { + return fmt.Errorf("legacy tree.json is nil") + } + + rootDir := filepath.Join(f.storageDir, "root") + if err := os.MkdirAll(rootDir, 0o755); err != nil { + return fmt.Errorf("ensure root dir: %w", err) + } + + for _, child := range legacyRoot.Children { + if err := f.migrateLegacyNode(rootDir, child); err != nil { + return err } - parent.Children = append(parent.Children, child) + } + + if err := f.writeLegacyOrderFile(rootDir, legacyRoot.Children); err != nil { + return err + } + + if err := f.ArchiveLegacyTreeJSON(); err != nil { + return fmt.Errorf("archive legacy tree.json after migration: %w", err) + } + + if err := f.WriteFSMigrationMarker(); err != nil { + return fmt.Errorf("write migration marker: %w", err) } return nil } -func (f *NodeStore) assignParentToChildren(parent *PageNode) { +func (f *NodeStore) migrateLegacyNode(parentDir string, n *legacyPageNode) error { + if n == nil { + return nil + } + + slug := strings.TrimSpace(n.Slug) + if slug == "" { + return fmt.Errorf("legacy node %q has empty slug", n.ID) + } + + switch n.Kind { + case NodeKindSection: + dirPath := filepath.Join(parentDir, slug) + if err := os.MkdirAll(dirPath, 0o755); err != nil { + return fmt.Errorf("create section dir %s: %w", dirPath, err) + } + + indexPath := filepath.Join(dirPath, "index.md") + if err := f.writeLegacyNodeMarkdown(indexPath, n); err != nil { + return err + } + + for _, child := range n.sortedChildren() { + if err := f.migrateLegacyNode(dirPath, child); err != nil { + return err + } + } + + if err := f.writeLegacyOrderFile(dirPath, n.Children); err != nil { + return err + } + return nil + + case NodeKindPage: + if len(n.Children) > 0 { + // Legacy invalid shape: page with children. + // Migrate safely as section to preserve subtree. + dirPath := filepath.Join(parentDir, slug) + if err := os.MkdirAll(dirPath, 0o755); err != nil { + return fmt.Errorf("create promoted section dir %s: %w", dirPath, err) + } + + indexPath := filepath.Join(dirPath, "index.md") + if err := f.writeLegacyNodeMarkdown(indexPath, n); err != nil { + return err + } + + for _, child := range n.sortedChildren() { + if err := f.migrateLegacyNode(dirPath, child); err != nil { + return err + } + } + + if err := f.writeLegacyOrderFile(dirPath, n.Children); err != nil { + return err + } + return nil + } + + filePath := filepath.Join(parentDir, slug+".md") + return f.writeLegacyNodeMarkdown(filePath, n) + + default: + return fmt.Errorf("legacy node %q has unknown kind %q", n.ID, n.Kind) + } +} + +func (n *legacyPageNode) sortedChildren() []*legacyPageNode { + out := append([]*legacyPageNode(nil), n.Children...) + sort.SliceStable(out, func(i, j int) bool { + if out[i].Position != out[j].Position { + return out[i].Position < out[j].Position + } + return out[i].ID < out[j].ID + }) + return out +} + +func (f *NodeStore) writeLegacyOrderFile(sectionDir string, children []*legacyPageNode) error { + if len(children) == 0 { + return nil + } + + sorted := append([]*legacyPageNode(nil), children...) + sort.SliceStable(sorted, func(i, j int) bool { + if sorted[i].Position != sorted[j].Position { + return sorted[i].Position < sorted[j].Position + } + return sorted[i].ID < sorted[j].ID + }) + + ids := make([]string, 0, len(sorted)) + for _, ch := range sorted { + if ch == nil || strings.TrimSpace(ch.ID) == "" { + continue + } + ids = append(ids, ch.ID) + } + + data, err := json.MarshalIndent(sectionOrderFile{Children: ids}, "", " ") + if err != nil { + return err + } + return shared.WriteFileAtomic(filepath.Join(sectionDir, "order.json"), data, 0o644) +} + +func (f *NodeStore) writeLegacyNodeMarkdown(path string, n *legacyPageNode) error { + body := "# " + strings.TrimSpace(n.Title) + "\n" + fm := markdown.Frontmatter{ + LeafWikiID: strings.TrimSpace(n.ID), + LeafWikiTitle: strings.TrimSpace(n.Title), + } + if !n.Metadata.CreatedAt.IsZero() { + fm.CreatedAt = n.Metadata.CreatedAt.UTC().Format(time.RFC3339) + } + if !n.Metadata.UpdatedAt.IsZero() { + fm.UpdatedAt = n.Metadata.UpdatedAt.UTC().Format(time.RFC3339) + } + fm.CreatorID = strings.TrimSpace(n.Metadata.CreatorID) + fm.LastAuthorID = strings.TrimSpace(n.Metadata.LastAuthorID) + + mf := markdown.NewMarkdownFile(path, body, fm) + if err := mf.WriteToFile(); err != nil { + return fmt.Errorf("write migrated markdown %s: %w", path, err) + } + return nil +} + +func (f *NodeStore) orderFilePathForSection(section *PageNode) (string, error) { + if section == nil { + return "", errors.New("section is nil") + } + if section.Kind != NodeKindSection { + return "", fmt.Errorf("node %s is not a section", section.ID) + } + dir, err := f.dirPathForNode(section) + if err != nil { + return "", err + } + return filepath.Join(dir, "order.json"), nil +} + +func (f *NodeStore) readChildOrder(parentDir string) ([]string, error) { + path := filepath.Join(parentDir, "order.json") + raw, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + + var file sectionOrderFile + if err := json.Unmarshal(raw, &file); err != nil { + return nil, err + } + return file.Children, nil +} +func (f *NodeStore) WriteChildOrder(parent *PageNode, orderedIDs []string) error { + if parent == nil { + return errors.New("parent is nil") + } + if parent.Kind != NodeKindSection { + return fmt.Errorf("parent %s is not a section", parent.ID) + } + + valid := make(map[string]bool, len(parent.Children)) for _, child := range parent.Children { - child.Parent = parent - f.assignParentToChildren(child) + valid[child.ID] = true + } + + seen := make(map[string]bool, len(orderedIDs)) + out := make([]string, 0, len(orderedIDs)) + for _, id := range orderedIDs { + if !valid[id] { + return fmt.Errorf("order contains non-child id %q", id) + } + if seen[id] { + return fmt.Errorf("order contains duplicate id %q", id) + } + seen[id] = true + out = append(out, id) + } + + path, err := f.orderFilePathForSection(parent) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + data, err := json.MarshalIndent(sectionOrderFile{Children: out}, "", " ") + if err != nil { + return err + } + return shared.WriteFileAtomic(path, data, 0o644) +} + +func (f *NodeStore) AppendChildOrder(parent *PageNode, childID string) error { + ids, err := f.normalizedOrderedChildren(parent) + if err != nil { + return err + } + for _, id := range ids { + if id == childID { + return nil + } + } + ids = append(ids, childID) + return f.WriteChildOrder(parent, ids) +} + +func (f *NodeStore) RemoveChildOrder(parent *PageNode, childID string) error { + ids, err := f.normalizedOrderedChildren(parent) + if err != nil { + return err + } + out := make([]string, 0, len(ids)) + for _, id := range ids { + if id != childID { + out = append(out, id) + } + } + return f.WriteChildOrder(parent, out) +} + +func (f *NodeStore) markDuplicateIDs(root *PageNode) { + seen := map[string]*PageNode{} + var walk func(*PageNode) + walk = func(n *PageNode) { + if n == nil { + return + } + if n.ID != "" && n.ID != "root" { + if prev, ok := seen[n.ID]; ok { + n.RepairNeeded = true + n.Issues = append(n.Issues, NodeIssue{ + Code: NodeIssueDuplicateID, + Message: fmt.Sprintf("duplicate leafwiki_id also used by %q", prev.CalculatePath()), + }) + prev.RepairNeeded = true + prev.Issues = append(prev.Issues, NodeIssue{ + Code: NodeIssueDuplicateID, + Message: fmt.Sprintf("duplicate leafwiki_id also used by %q", n.CalculatePath()), + }) + } else { + seen[n.ID] = n + } + } + for _, ch := range n.Children { + walk(ch) + } + } + walk(root) +} + +func (f *NodeStore) currentOrderedChildren(parent *PageNode) ([]string, error) { + if parent == nil { + return nil, errors.New("parent is nil") } + if parent.Kind != NodeKindSection { + return nil, fmt.Errorf("parent %s is not a section", parent.ID) + } + dir, err := f.dirPathForNode(parent) + if err != nil { + return nil, err + } + return f.readChildOrder(dir) } -func (f *NodeStore) SaveTree(filename string, tree *PageNode) error { - if tree == nil { - return errors.New("a tree is required") +func (f *NodeStore) sortChildrenForParent(parentDir string, children []*PageNode, parent *PageNode) []*PageNode { + orderIDs, err := f.readChildOrder(parentDir) + if err != nil { + parent.RepairNeeded = true + parent.Issues = append(parent.Issues, NodeIssue{ + Code: NodeIssueInvalidOrder, + Message: "order.json could not be parsed; falling back to deterministic order", + }) + orderIDs = nil + } + + byID := make(map[string]*PageNode, len(children)) + for _, child := range children { + byID[child.ID] = child } - fullPath := filepath.Join(f.storageDir, filename) + var ordered []*PageNode + seen := make(map[string]bool, len(children)) + unknownIDs := false + + for _, id := range orderIDs { + if child, ok := byID[id]; ok { + if !seen[id] { + ordered = append(ordered, child) + seen[id] = true + } + } else { + unknownIDs = true + } + } + + if unknownIDs { + parent.RepairNeeded = true + parent.Issues = append(parent.Issues, NodeIssue{ + Code: NodeIssueInvalidOrder, + Message: "order.json references unknown child ids", + }) + } + + var tail []*PageNode + for _, child := range children { + if !seen[child.ID] { + tail = append(tail, child) + } + } + + sort.SliceStable(tail, func(i, j int) bool { + si := strings.ToLower(tail[i].Slug) + sj := strings.ToLower(tail[j].Slug) + if si == sj { + return tail[i].Slug < tail[j].Slug + } + return si < sj + }) + + return append(ordered, tail...) +} - data, err := json.Marshal(tree) +func (f *NodeStore) normalizedOrderedChildren(parent *PageNode) ([]string, error) { + raw, err := f.currentOrderedChildren(parent) if err != nil { - return fmt.Errorf("could not marshal tree: %w", err) + return nil, err + } + + valid := make(map[string]bool, len(parent.Children)) + for _, child := range parent.Children { + valid[child.ID] = true } - if err := shared.WriteFileAtomic(fullPath, data, 0o644); err != nil { - return fmt.Errorf("could not atomically write tree file: %w", err) + seen := make(map[string]bool, len(raw)) + out := make([]string, 0, len(raw)) + for _, id := range raw { + if !valid[id] || seen[id] { + continue + } + seen[id] = true + out = append(out, id) + } + return out, nil +} + +// WriteNodeFrontmatter explicitly persists node metadata to the canonical content file. +// For sections, this may create index.md if missing. +// For pages, a missing file is treated as drift. +func (f *NodeStore) WriteNodeFrontmatter(entry *PageNode) error { + if entry == nil { + return &InvalidOpError{Op: "WriteNodeFrontmatter", Reason: "an entry is required"} + } + if entry.ID == "root" { + return nil + } + + filePath, err := f.contentPathForNodeWrite(entry) + if err != nil { + return err + } + + var mdFile *markdown.MarkdownFile + + if _, err := os.Stat(filePath); err == nil { + mdFile, err = markdown.LoadMarkdownFile(filePath) + if err != nil { + return fmt.Errorf("could not load markdown file %s: %w", filePath, err) + } + } else if errors.Is(err, os.ErrNotExist) { + if entry.Kind == NodeKindPage { + return &DriftError{ + NodeID: entry.ID, + Kind: entry.Kind, + Path: filePath, + Reason: "expected page file missing", + } + } + mdFile = markdown.NewMarkdownFile(filePath, "", markdown.Frontmatter{}) + } else { + return fmt.Errorf("could not stat markdown file %s: %w", filePath, err) + } + + mdFile.SetFrontmatterID(entry.ID) + mdFile.SetFrontmatterTitle(entry.Title) + mdFile.SetFrontmatterMetadata( + formatRFC3339(entry.Metadata.CreatedAt), + entry.Metadata.CreatorID, + formatRFC3339(entry.Metadata.UpdatedAt), + entry.Metadata.LastAuthorID, + ) + + if err := mdFile.WriteToFile(); err != nil { + return fmt.Errorf("could not write markdown file %s: %w", filePath, err) } return nil } +// syntheticNodeID is only a temporary projection fallback for nodes missing leafwiki_id. +// It must not be treated as stable identity across renames or moves. +func syntheticNodeID(path string) string { + return "missing-id:" + strings.ReplaceAll(path, string(filepath.Separator), "/") +} + // CreatePage creates a new page file under the given parent entry func (f *NodeStore) CreatePage(parentEntry *PageNode, newEntry *PageNode) error { if parentEntry == nil { @@ -324,13 +789,16 @@ func (f *NodeStore) CreatePage(parentEntry *PageNode, newEntry *PageNode) error } // Build and write file - fm := markdown.Frontmatter{LeafWikiID: newEntry.ID} - md, err := markdown.BuildMarkdownWithFrontmatter(fm, "# "+newEntry.Title+"\n") - if err != nil { - return fmt.Errorf("could not build markdown with frontmatter: %w", err) - } - - if err := shared.WriteFileAtomic(destFile, []byte(md), 0o644); err != nil { + mf := markdown.NewMarkdownFile(destFile, "# "+newEntry.Title+"\n", markdown.Frontmatter{}) + mf.SetFrontmatterID(newEntry.ID) + mf.SetFrontmatterTitle(newEntry.Title) + mf.SetFrontmatterMetadata( + formatRFC3339(newEntry.Metadata.CreatedAt), + newEntry.Metadata.CreatorID, + formatRFC3339(newEntry.Metadata.UpdatedAt), + newEntry.Metadata.LastAuthorID, + ) + if err := mf.WriteToFile(); err != nil { return fmt.Errorf("could not create file: %w", err) } @@ -348,8 +816,6 @@ func (f *NodeStore) CreateSection(parentEntry *PageNode, newEntry *PageNode) err if newEntry.ID == "root" { return &InvalidOpError{Op: "CreateSection", Reason: "cannot create root"} } - - // Sections can only be created under sections (Option A) if parentEntry.Kind != NodeKindSection { return &InvalidOpError{Op: "CreateSection", Reason: "parent entry must be a section"} } @@ -357,64 +823,81 @@ func (f *NodeStore) CreateSection(parentEntry *PageNode, newEntry *PageNode) err return &InvalidOpError{Op: "CreateSection", Reason: "new entry must be a section"} } - // Parent directory from tree path parentDir, err := f.dirPathForNode(parentEntry) if err != nil { return err } - // Ensure parent directory exists (idempotent) if err := os.MkdirAll(parentDir, 0o755); err != nil { return fmt.Errorf("could not ensure parent directory exists: %w", err) } - // Destination base paths destBase := filepath.Join(parentDir, newEntry.Slug) destFile := destBase + ".md" destDir := destBase - // Reject if either a file OR a directory with same slug exists if fileExists(destFile) || fileExists(destDir) { return &PageAlreadyExistsError{Path: destBase} } - // Create the folder for the section (no index.md by default) if err := os.MkdirAll(destDir, 0o755); err != nil { return fmt.Errorf("could not create section folder: %w", err) } + indexPath := filepath.Join(destDir, "index.md") + mf := markdown.NewMarkdownFile(indexPath, "# "+newEntry.Title+"\n", markdown.Frontmatter{}) + mf.SetFrontmatterID(newEntry.ID) + mf.SetFrontmatterTitle(newEntry.Title) + mf.SetFrontmatterMetadata( + formatRFC3339(newEntry.Metadata.CreatedAt), + newEntry.Metadata.CreatorID, + formatRFC3339(newEntry.Metadata.UpdatedAt), + newEntry.Metadata.LastAuthorID, + ) + + if err := mf.WriteToFile(); err != nil { + return fmt.Errorf("could not create section index.md: %w", err) + } + return nil } // UpsertContent updates the content of a page file on disk // It creates the file if it does not exist also for sections (index.md) func (f *NodeStore) UpsertContent(entry *PageNode, content string) error { + if entry == nil { return &InvalidOpError{Op: "UpsertContent", Reason: "an entry is required"} } - // Determine expected write path filePath, err := f.contentPathForNodeWrite(entry) if err != nil { return err } - mode := os.FileMode(0o644) - if st, err := os.Stat(filePath); err == nil { - mode = st.Mode() + var mf *markdown.MarkdownFile + if _, err := os.Stat(filePath); err == nil { + mf, err = markdown.LoadMarkdownFile(filePath) + if err != nil { + return err + } + } else if errors.Is(err, os.ErrNotExist) { + mf = markdown.NewMarkdownFile(filePath, "", markdown.Frontmatter{}) + } else { + return err } - // Update the file content - fm := markdown.Frontmatter{LeafWikiID: strings.TrimSpace(entry.ID), LeafWikiTitle: strings.TrimSpace(entry.Title)} - contentWithFM, err := markdown.BuildMarkdownWithFrontmatter(fm, content) - if err != nil { - return fmt.Errorf("could not build markdown with frontmatter: %w", err) - } - if err := shared.WriteFileAtomic(filePath, []byte(contentWithFM), mode); err != nil { - return fmt.Errorf("could not write to file atomically: %w", err) - } + mf.SetFrontmatterID(entry.ID) + mf.SetFrontmatterTitle(entry.Title) + mf.SetFrontmatterMetadata( + formatRFC3339(entry.Metadata.CreatedAt), + entry.Metadata.CreatorID, + formatRFC3339(entry.Metadata.UpdatedAt), + entry.Metadata.LastAuthorID, + ) + mf.SetContent(content) - return nil + return mf.WriteToFile() } // MoveNode moves a page to a other node @@ -703,62 +1186,6 @@ func (f *NodeStore) ReadPageContent(entry *PageNode) (string, error) { return content, nil } -// SyncFrontmatterIfExists updates the frontmatter of a page file on disk if it exists -func (f *NodeStore) SyncFrontmatterIfExists(entry *PageNode) error { - if entry == nil { - return &InvalidOpError{Op: "SyncFrontmatterIfExists", Reason: "an entry is required"} - } - - // keine side effects: write-path NICHT verwenden (würde mkdir + bei Section implizit index.md Pfad liefern) - // aber read-path reicht, weil wir nur syncen, wenn Datei existiert - filePath, err := f.contentPathForNodeRead(entry) - if err != nil { - return err - } - - // Datei existiert? - if !fileExists(filePath) { - // Page: muss existieren - if entry.Kind == NodeKindPage || entry.Kind == "" { - return &DriftError{NodeID: entry.ID, Kind: entry.Kind, Path: filePath, Reason: "expected page file missing"} - } - // Section: kein index.md -> NICHT erzeugen - return nil - } - - raw, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("read content file: %w", err) - } - - fm, body, has, err := markdown.ParseFrontmatter(string(raw)) - if err != nil { - return fmt.Errorf("parse frontmatter: %w", err) - } - if !has { - fm = markdown.Frontmatter{} - } - - // Tree-SoT invariants - fm.LeafWikiID = strings.TrimSpace(entry.ID) - fm.LeafWikiTitle = strings.TrimSpace(entry.Title) - - out, err := markdown.BuildMarkdownWithFrontmatter(fm, body) - if err != nil { - return fmt.Errorf("build markdown: %w", err) - } - - mode := os.FileMode(0o644) - if st, err := os.Stat(filePath); err == nil { - mode = st.Mode() - } - - if err := shared.WriteFileAtomic(filePath, []byte(out), mode); err != nil { - return fmt.Errorf("write file atomically: %w", err) - } - return nil -} - func (f *NodeStore) dirPathForNode(entry *PageNode) (string, error) { if entry == nil { return "", &InvalidOpError{Op: "dirPathForNode", Reason: "an entry is required"} @@ -817,52 +1244,9 @@ func (f *NodeStore) contentPathForNodeWrite(entry *PageNode) (string, error) { } } -// resolveNode inspects the filesystem to determine if the given PageNode -// corresponds to a file or folder, returning a ResolvedNode with details. -// This function is only used for migration. Other parts of the system should rely on contentPathForNodeRead or contentPathForNodeWrite. -// If this function is used outside of migration, it may lead to inconsistencies between the tree and the actual filesystem state. -func (f *NodeStore) resolveNode(entry *PageNode) (*ResolvedNode, error) { - basePath, err := f.dirPathForNode(entry) - if err != nil { - return nil, err - } - - // 1) File? - if _, err := os.Stat(basePath + ".md"); err == nil { - f.log.Debug("resolved as file node", "filePath", basePath+".md") - return &ResolvedNode{ - Kind: NodeKindPage, - FilePath: basePath + ".md", - HasContent: true, - }, nil - } - - // 2) Folder? - if info, err := os.Stat(basePath); err == nil && info.IsDir() { - index := filepath.Join(basePath, "index.md") - if _, err := os.Stat(index); err == nil { - f.log.Debug("resolved as section node with content", "dirPath", basePath, "filePath", index) - return &ResolvedNode{ - Kind: NodeKindSection, - DirPath: basePath, - FilePath: index, - HasContent: true, - }, nil - } - f.log.Debug("resolved as section node without content", "dirPath", basePath) - return &ResolvedNode{ - Kind: NodeKindSection, - DirPath: basePath, - FilePath: "", // no index.md present - HasContent: false, - }, nil - } - - return nil, &NotFoundError{Resource: "node", Path: basePath, ID: entry.ID} -} - -// ConvertNode converts the on-disk representation between page <-> folder. -// NOTE: TreeService must ensure folder->page is allowed (no children). +// ConvertNode converts on-disk representation between page-file and section-folder. +// This is transitional infrastructure, not a canonical "kind metadata update". +// Kind is derived from the resulting filesystem shape. func (f *NodeStore) ConvertNode(entry *PageNode, target NodeKind) error { if entry == nil { return &InvalidOpError{Op: "ConvertNode", Reason: "an entry is required"} diff --git a/internal/core/tree/node_store_reconstruct_test.go b/internal/core/tree/node_store_reconstruct_test.go index 6c316a77..6cfa2e7a 100644 --- a/internal/core/tree/node_store_reconstruct_test.go +++ b/internal/core/tree/node_store_reconstruct_test.go @@ -217,7 +217,6 @@ func TestNodeStore_ReconstructTreeFromFS_PositionsAreContiguous(t *testing.T) { } } - func TestNodeStore_ReconstructTreeFromFS_WritesIDsBackToFiles(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) @@ -319,4 +318,4 @@ func TestNodeStore_ReconstructTreeFromFS_SkipsInvalidSlugs(t *testing.T) { if validSection == nil { t.Fatalf("expected 'Valid Section' directory to be normalized to 'valid-section'") } -} \ No newline at end of file +} diff --git a/internal/core/tree/page_node.go b/internal/core/tree/page_node.go index 50f448fb..9219e742 100644 --- a/internal/core/tree/page_node.go +++ b/internal/core/tree/page_node.go @@ -5,7 +5,6 @@ import ( "encoding/binary" "encoding/hex" "io" - "sort" "time" ) @@ -24,19 +23,41 @@ const ( NodeKindSection NodeKind = "section" ) -// PageNode represents a single node in the tree -// It has an ID, a parent, a path, and children -// The ID is a unique identifier for the entry +type NodeIssueCode string + +const ( + NodeIssueMissingIndexMD NodeIssueCode = "missing_index_md" + NodeIssueMissingID NodeIssueCode = "missing_leafwiki_id" + NodeIssueDuplicateID NodeIssueCode = "duplicate_leafwiki_id" + NodeIssueInvalidOrder NodeIssueCode = "invalid_order_json" +) + +type NodeIssue struct { + Code NodeIssueCode `json:"code"` + Message string `json:"message"` +} + +// This is an in-memory projection reconstructed from the filesystem. +// Parent/Children/Kind are derived, not persisted truth. type PageNode struct { - ID string `json:"id"` // Unique identifier for the entry - Title string `json:"title"` // Title is the name of the entry - Slug string `json:"slug"` // Slug is the path of the entry - Children []*PageNode `json:"children"` // Children are the children of the entry - Position int `json:"position"` // Position is the position of the entry + ID string `json:"id"` // Unique identifier for the entry + Title string `json:"title"` // Title is the name of the entry + Slug string `json:"slug"` // Slug is the path of the entry + Metadata PageMetadata `json:"metadata"` // Metadata holds metadata about the page + + // Derived fields (not persisted) Parent *PageNode `json:"-"` + Children []*PageNode `json:"children,omitempty"` + Kind NodeKind `json:"kind"` - Kind NodeKind `json:"kind"` // Kind is the kind of the node (page or folder) - Metadata PageMetadata `json:"metadata"` // Metadata holds metadata about the page + // Visible repair/drift markers for app/browser + RepairNeeded bool `json:"repairNeeded"` + Issues []NodeIssue `json:"issues,omitempty"` +} + +func (p *PageNode) Hash() string { + sum := p.hashSum(true) // includeMetadata = true + return hex.EncodeToString(sum[:]) } func (p *PageNode) HasChildren() bool { @@ -52,12 +73,12 @@ func (p *PageNode) ChildAlreadyExists(slug string) bool { return false } -func (p *PageNode) IsChildOf(childID string, recursive bool) bool { +func (p *PageNode) HasDescendant(childID string, recursive bool) bool { for _, child := range p.Children { if child.ID == childID { return true } - if recursive && child.IsChildOf(childID, recursive) { + if recursive && child.HasDescendant(childID, recursive) { return true } } @@ -76,13 +97,6 @@ func (p *PageNode) CalculatePath() string { return p.Parent.CalculatePath() + "/" + p.Slug } -// Hash returns a deterministic hash of the node and all descendants. -// Parent is intentionally ignored to avoid cycles. -func (p *PageNode) Hash() string { - sum := p.hashSum(true) // includeMetadata = true - return hex.EncodeToString(sum[:]) -} - func (p *PageNode) hashSum(includeMetadata bool) [32]byte { h := sha256.New() @@ -105,8 +119,12 @@ func (p *PageNode) writeHashPayload(w io.Writer, includeMetadata bool) { writeString(w, p.Slug) writeString(w, "kind") writeString(w, string(p.Kind)) - writeString(w, "position") - writeInt64(w, int64(p.Position)) + + if p.RepairNeeded { + writeInt64(w, 1) + } else { + writeInt64(w, 0) + } if includeMetadata { writeString(w, "meta.createdAt") @@ -119,24 +137,9 @@ func (p *PageNode) writeHashPayload(w io.Writer, includeMetadata bool) { writeString(w, p.Metadata.LastAuthorID) } - // Children: enforce stable order (Position, then ID as tie-breaker) - children := make([]*PageNode, 0, len(p.Children)) - children = append(children, p.Children...) - - sort.SliceStable(children, func(i, j int) bool { - if children[i] == nil || children[j] == nil { - return children[j] != nil // nils last - } - if children[i].Position != children[j].Position { - return children[i].Position < children[j].Position - } - return children[i].ID < children[j].ID - }) - writeString(w, "children.count") - writeInt64(w, int64(len(children))) - - for _, ch := range children { + writeInt64(w, int64(len(p.Children))) + for _, ch := range p.Children { if ch == nil { writeString(w, "child.nil") continue diff --git a/internal/core/tree/tree_service.go b/internal/core/tree/tree_service.go index 20cf382a..bdc46aa6 100644 --- a/internal/core/tree/tree_service.go +++ b/internal/core/tree/tree_service.go @@ -1,27 +1,22 @@ package tree import ( - "errors" "fmt" "log/slog" - "os" - "sort" "strings" "sync" "time" - "github.com/perber/wiki/internal/core/markdown" "github.com/perber/wiki/internal/core/shared" ) // TreeService is our main component for handling tree operations // We use this service to create pages, delete pages, update pages, etc. type TreeService struct { - storageDir string - treeFilename string - tree *PageNode - store *NodeStore - log *slog.Logger + storageDir string + tree *PageNode + store *NodeStore + log *slog.Logger mu sync.RWMutex } @@ -29,11 +24,10 @@ type TreeService struct { // NewTreeService creates a new TreeService func NewTreeService(storageDir string) *TreeService { return &TreeService{ - storageDir: storageDir, - treeFilename: "tree.json", - tree: nil, - store: NewNodeStore(storageDir), - log: slog.Default().With("component", "TreeService"), + storageDir: storageDir, + tree: nil, + store: NewNodeStore(storageDir), + log: slog.Default().With("component", "TreeService"), } } @@ -43,274 +37,23 @@ func (t *TreeService) LoadTree() error { t.mu.Lock() defer t.mu.Unlock() - // Load the tree from the storage directory - var err error - t.tree, err = t.store.LoadTree(t.treeFilename) - if err != nil { + // One-time import from legacy tree.json into filesystem model. + // Only runs if tree.json exists and root/ is still empty/unmigrated. + if err := t.store.MigrateLegacyTreeJSONToFS(); err != nil { + t.log.Error("legacy tree.json migration failed", "error", err) return err } - // Load the schema version - t.log.Info("Checking schema version...") - schema, err := loadSchema(t.storageDir) + // Load the tree from the storage directory + var err error + t.tree, err = t.store.ReconstructTreeFromFS() if err != nil { - t.log.Error("Error loading schema", "error", err) return err } - if schema.Version < CurrentSchemaVersion { - t.log.Info("Migrating schema", "fromVersion", schema.Version, "toVersion", CurrentSchemaVersion) - if err := t.migrate(schema.Version); err != nil { - t.log.Error("Error migrating schema", "error", err) - return err - } - } - - return err -} - -func (t *TreeService) migrate(fromVersion int) error { - - for v := fromVersion; v < CurrentSchemaVersion; v++ { - switch v { - case 0: - if err := t.migrateToV1(); err != nil { - t.log.Error("Error migrating to v1", "error", err) - return err - } - case 1: - if err := t.migrateToV2(); err != nil { - t.log.Error("Error migrating to v2", "error", err) - return err - } - } - - // Save the tree after each migration step - if err := t.saveTreeLocked(); err != nil { - t.log.Error("Error saving tree after migration", "version", v+1, "error", err) - return err - } - - // Update the schema version file - if err := saveSchema(t.storageDir, v+1); err != nil { - t.log.Error("Error saving schema", "version", v+1, "error", err) - return err - } - } return nil } -func (t *TreeService) migrateToV1() error { - if t.tree == nil { - return ErrTreeNotLoaded - } - - return t.backfillMetadataLocked() -} - -// backfillMetadataLocked backfills CreatedAt and UpdatedAt timestamps for all nodes from filesystem -// The caller must ensure that t.tree is not nil and must hold the appropriate lock before calling this method -func (t *TreeService) backfillMetadataLocked() error { - var backfillMetadata func(node *PageNode) error - backfillMetadata = func(node *PageNode) error { - // If CreatedAt is already set, assume metadata was backfilled and skip - if !node.Metadata.CreatedAt.IsZero() { - return nil - } - - // Read creation and modification times from the filesystem - // and set them in the metadata - - r, err := t.store.resolveNode(node) - if err != nil { - // Log and continue (same behavior as before) - t.log.Error("Could not resolve node for metadata backfill", "nodeID", node.ID, "error", err) - return nil - } - - // Prefer the real on-disk object: - // - Page => .md - // - Folder with content => /index.md - // - Folder without content => use folder mtime - statPath := r.FilePath - if r.Kind == NodeKindSection && !r.HasContent { - statPath = r.DirPath - } - - // The default value is set to now - createdAt := time.Now().UTC() - updatedAt := time.Now().UTC() - - if statPath != "" { - if info, err := os.Stat(statPath); err == nil { - createdAt = info.ModTime().UTC() - updatedAt = info.ModTime().UTC() - } else if !os.IsNotExist(err) { - t.log.Error("Could not stat node for metadata", "nodeID", node.ID, "path", statPath, "error", err) - } - } - - node.Metadata = PageMetadata{ - CreatedAt: createdAt, - UpdatedAt: updatedAt, - } - - // Recurse into children - for _, child := range node.Children { - if err := backfillMetadata(child); err != nil { - return err - } - } - - return nil - } - - return backfillMetadata(t.tree) -} - -// migrateToV2 migrates the tree to the v2 schema -// Adds frontmatter to all existing pages if missing -// Adds kind to all nodes -func (t *TreeService) migrateToV2() error { - if t.tree == nil { - return ErrTreeNotLoaded - } - t.backfillKindFromFSLocked() - - // Traverse all pages and add frontmatter if missing - var addFrontmatter func(node *PageNode) error - addFrontmatter = func(node *PageNode) error { - // Read the content of the page - content, err := t.store.ReadPageRaw(node) - if err != nil { - if errors.Is(err, os.ErrNotExist) || errors.Is(err, ErrFileNotFound) { - t.log.Warn("Page file does not exist, skipping frontmatter addition", "nodeID", node.ID) - // Recurse into children - for _, child := range node.Children { - if err := addFrontmatter(child); err != nil { - t.log.Error("Error adding frontmatter to child node", "nodeID", child.ID, "error", err) - return err - } - } - return nil - } - t.log.Error("Could not read page content for node", "nodeID", node.ID, "error", err) - return fmt.Errorf("could not read page content for node %s: %w", node.ID, err) - } - - // Parse the frontmatter - fm, body, has, err := markdown.ParseFrontmatter(content) - if err != nil { - t.log.Error("Could not parse frontmatter for node", "nodeID", node.ID, "error", err) - return fmt.Errorf("could not parse frontmatter for node %s: %w", node.ID, err) - } - - // Decide if we need to change anything - changed := false - - // If there is no frontmatter, start with a new one - if !has { - fm = markdown.Frontmatter{} - changed = true - } - - // Ensure required fields exist - if strings.TrimSpace(fm.LeafWikiID) == "" { - fm.LeafWikiID = node.ID - changed = true - } - // Optional but nice: keep title in sync *at least once* - // (you might choose to NOT overwrite existing title) - if strings.TrimSpace(fm.LeafWikiTitle) == "" { - fm.LeafWikiTitle = node.Title - changed = true - } - - // Only write if changed - if changed { - newContent, err := markdown.BuildMarkdownWithFrontmatter(fm, body) - if err != nil { - t.log.Error("could not build markdown with frontmatter", "nodeID", node.ID, "error", err) - return fmt.Errorf("could not build markdown with frontmatter for node %s: %w", node.ID, err) - } - - filePath, err := t.store.contentPathForNodeWrite(node) - if err != nil { - return fmt.Errorf("could not determine content path for node %s: %w", node.ID, err) - } - - if err := shared.WriteFileAtomic(filePath, []byte(newContent), 0o644); err != nil { - t.log.Error("could not write updated page content", "nodeID", node.ID, "filePath", filePath, "error", err) - return fmt.Errorf("could not write updated page content for node %s: %w", node.ID, err) - } - - t.log.Info("frontmatter backfilled", "nodeID", node.ID, "path", filePath) - } - - // Recurse into children - for _, child := range node.Children { - if err := addFrontmatter(child); err != nil { - t.log.Error("Error adding frontmatter to child node", "nodeID", child.ID, "error", err) - return err - } - } - - return nil - } - - // start the recursion from the children of the root - for _, child := range t.tree.Children { - if err := addFrontmatter(child); err != nil { - t.log.Error("Error adding frontmatter to child node", "nodeID", child.ID, "error", err) - return err - } - } - - return nil -} - -func (t *TreeService) backfillKindFromFSLocked() { - if t.tree == nil { - return - } - t.tree.Kind = NodeKindSection - - var walk func(n *PageNode) - walk = func(n *PageNode) { - if n == nil { - return - } - - // Root skip - if n.ID != "root" { - // Nur backfillen, wenn Kind fehlt/unknown - if n.Kind != NodeKindPage && n.Kind != NodeKindSection { - r, err := t.store.resolveNode(n) - if err == nil { - n.Kind = r.Kind - } else { - // Fallback-Heuristik, wenn auf Disk nichts existiert - if n.HasChildren() { - n.Kind = NodeKindSection - } else { - n.Kind = NodeKindPage - } - t.log.Warn("could not resolve node on disk; kind backfilled by heuristic", - "nodeID", n.ID, "slug", n.Slug, "err", err, "kind", n.Kind) - } - } - } - - for _, ch := range n.Children { - walk(ch) - } - } - - for _, ch := range t.tree.Children { - walk(ch) - } -} - func (t *TreeService) withLockedTree(fn func() error) error { t.mu.Lock() defer t.mu.Unlock() @@ -325,20 +68,31 @@ func (t *TreeService) withRLockedTree(fn func() error) error { return fn() } -// SaveTree saves the tree to the storage directory -func (t *TreeService) SaveTree() error { - return t.withLockedTree(t.saveTreeLocked) +func (t *TreeService) ReloadProjection() error { + return t.withLockedTree(t.reloadProjectionLocked) } -// saveTreeLocked saves the tree to the storage directory -func (t *TreeService) saveTreeLocked() error { - return t.store.SaveTree(t.treeFilename, t.tree) +func (t *TreeService) reloadProjectionLocked() error { + newTree, err := t.store.ReconstructTreeFromFS() + if err != nil { + return fmt.Errorf("reconstruct tree from fs: %w", err) + } + if newTree == nil { + return fmt.Errorf("internal error: reconstructed tree is nil") + } + t.tree = newTree + return nil } // TreeHash returns the current hash of the tree func (t *TreeService) TreeHash() string { var hash string _ = t.withRLockedTree(func() error { + + if t.tree == nil { + hash = "" + return nil + } hash = t.tree.Hash() return nil }) @@ -351,8 +105,6 @@ func (t *TreeService) ReconstructTreeFromFS() error { } func (t *TreeService) reconstructTreeFromFSLocked() error { - // Reconstruct the tree from the filesystem - // This is a more complex operation and may involve reading the filesystem structure newTree, err := t.store.ReconstructTreeFromFS() if err != nil { t.log.Error("Error reconstructing tree from filesystem", "error", err) @@ -364,34 +116,8 @@ func (t *TreeService) reconstructTreeFromFSLocked() error { return fmt.Errorf("internal error: ReconstructTreeFromFS returned nil tree") } - // Save the old tree in case we need to revert - // Note: oldTree may be nil if this is the first reconstruction (which is expected) - oldTree := t.tree t.tree = newTree - // Backfill metadata for all nodes - if err := t.backfillMetadataLocked(); err != nil { - t.log.Error("Error backfilling metadata after reconstruction", "error", err) - // Revert tree assignment on failure (may set back to nil, which is fine) - t.tree = oldTree - return err - } - - // Save the tree - if err := t.saveTreeLocked(); err != nil { - t.log.Error("Error saving tree after reconstruction", "error", err) - // Revert tree assignment on failure (may set back to nil, which is fine) - t.tree = oldTree - return err - } - - // Update the schema version to prevent unnecessary migrations on next startup - if err := saveSchema(t.storageDir, CurrentSchemaVersion); err != nil { - t.log.Error("Error saving schema after reconstruction", "error", err) - // Note: We don't revert the tree here since it was already saved successfully - return err - } - return nil } @@ -442,6 +168,7 @@ func (t *TreeService) createNodeLocked(userID string, parentID *string, title st if err := t.store.ConvertNode(parent, NodeKindSection); err != nil { return nil, fmt.Errorf("could not convert parent node: %w", err) } + // Transitional in-memory update; authoritative kind comes from reloadProjectionLocked(). parent.Kind = NodeKindSection } @@ -463,7 +190,6 @@ func (t *TreeService) createNodeLocked(userID string, parentID *string, title st Parent: parent, Slug: slug, Kind: k, - Position: len(parent.Children), // Set the position to the end of the list Children: []*PageNode{}, Metadata: PageMetadata{ CreatedAt: now, @@ -486,17 +212,26 @@ func (t *TreeService) createNodeLocked(userID string, parentID *string, title st } // Add the new page to the parent - parent.Children = append(parent.Children, entry) + if err := t.store.AppendChildOrder(parent, entry.ID); err != nil { + return nil, fmt.Errorf("could not append order entry: %w", err) + } + + if err := t.reloadProjectionLocked(); err != nil { + return nil, fmt.Errorf("could not reload tree projection: %w", err) + } return &entry.ID, nil } // FindPageByID finds a page in the tree by its ID // If the page is not found, it returns an error -func (t *TreeService) FindPageByID(entry []*PageNode, id string) (*PageNode, error) { +func (t *TreeService) FindPageByID(id string) (*PageNode, error) { var result *PageNode err := t.withRLockedTree(func() error { + if t.tree == nil { + return ErrTreeNotLoaded + } var err error - result, err = t.findPageByIDLocked(entry, id) + result, err = t.findPageByIDLocked(t.tree.Children, id) return err }) @@ -551,16 +286,7 @@ func (t *TreeService) DeleteNode(userID string, id string, recursive bool) error } case NodeKindPage: if node.HasChildren() { - // This should not happen due to earlier check, but just in case - // Convert to section and delete recursively - t.log.Info("converting page to section for recursive delete", "pageID", node.ID) - if err := t.store.ConvertNode(node, NodeKindSection); err != nil { - return fmt.Errorf("could not convert page to section: %w", err) - } - node.Kind = NodeKindSection - if err := t.store.DeleteSection(node); err != nil { - return fmt.Errorf("could not delete section entry: %w", err) - } + return fmt.Errorf("invalid projection: page node %q has children", node.ID) } else { if err := t.store.DeletePage(node); err != nil { return fmt.Errorf("could not delete page entry: %w", err) @@ -570,16 +296,12 @@ func (t *TreeService) DeleteNode(userID string, id string, recursive bool) error return fmt.Errorf("unknown node kind: %v", node.Kind) } - // Remove the page from the parent - for i, e := range parent.Children { - if e.ID == id { - parent.Children = append(parent.Children[:i], parent.Children[i+1:]...) - break - } + if err := t.store.RemoveChildOrder(parent, id); err != nil { + return fmt.Errorf("could not update order.json after delete: %w", err) } - t.reindexPositions(parent) - return t.saveTreeLocked() + return t.reloadProjectionLocked() + }) return err } @@ -602,22 +324,6 @@ func (t *TreeService) UpdateNode(userID string, id string, title string, slug st return ErrPageAlreadyExists } - // Kind change? - // This operation is currently disabled to avoid complexity with content migration. - // We need to check if we need it later. - // if kind != nil && *kind != node.Kind { - // // Section -> Page only allowed if no children - // if node.Kind == NodeKindSection && *kind == NodeKindPage && node.HasChildren() { - // return ErrPageHasChildren - // } - - // t.log.Info("changing node kind", "nodeID", node.ID, "oldKind", node.Kind, "newKind", *kind) - // if err := t.store.ConvertNode(node, *kind); err != nil { - // return fmt.Errorf("could not convert node: %w", err) - // } - // node.Kind = *kind - // } - // Content update? if content != nil { t.log.Info("updating node content", "nodeID", node.ID) @@ -642,13 +348,11 @@ func (t *TreeService) UpdateNode(userID string, id string, title string, slug st node.Metadata.UpdatedAt = time.Now().UTC() node.Metadata.LastAuthorID = userID - // Keep frontmatter in sync *if file exists* (important when title changed but content == nil) - if err := t.store.SyncFrontmatterIfExists(node); err != nil { - return fmt.Errorf("could not sync frontmatter: %w", err) + if err := t.store.WriteNodeFrontmatter(node); err != nil { + return fmt.Errorf("could not write node frontmatter: %w", err) } - // Save tree - return t.saveTreeLocked() + return t.reloadProjectionLocked() }) } @@ -670,7 +374,9 @@ func (t *TreeService) ConvertNode(userID string, id string, kind NodeKind) error return nil } - // Section -> Page only allowed if no children + // Explicit kind conversion is no longer a primary domain operation. + // Kind is derived from FS representation. + // We still keep a transitional implementation for controlled cases. if node.Kind == NodeKindSection && kind == NodeKindPage && node.HasChildren() { return ErrPageHasChildren } @@ -680,19 +386,14 @@ func (t *TreeService) ConvertNode(userID string, id string, kind NodeKind) error if err := t.store.ConvertNode(node, kind); err != nil { return fmt.Errorf("could not convert node: %w", err) } - node.Kind = kind - - // Update metadata node.Metadata.UpdatedAt = time.Now().UTC() node.Metadata.LastAuthorID = userID - // Keep frontmatter in sync *if file exists* (important when kind changed but content == nil) - if err := t.store.SyncFrontmatterIfExists(node); err != nil { - return fmt.Errorf("could not sync frontmatter: %w", err) + if err := t.store.WriteNodeFrontmatter(node); err != nil { + return fmt.Errorf("could not write node frontmatter after convert: %w", err) } - // Save tree - return t.saveTreeLocked() + return t.reloadProjectionLocked() }) } @@ -736,11 +437,20 @@ func (t *TreeService) FindPageByRoutePath(entry []*PageNode, routePath string) ( t.mu.RLock() defer t.mu.RUnlock() + routePath = strings.TrimSpace(routePath) + routePath = strings.Trim(routePath, "/") + if routePath == "" { + return nil, ErrPageNotFound + } + // Split the routePath into parts routePart := strings.Split(routePath, "/") // recursive function to find the entry var findEntry func(entry []*PageNode, routePart []string) (*Page, error) findEntry = func(entry []*PageNode, routePart []string) (*Page, error) { + if len(routePart) == 0 { + return nil, ErrPageNotFound + } for _, e := range entry { if e.Slug == routePart[0] { if len(routePart) == 1 { @@ -931,11 +641,6 @@ func (t *TreeService) EnsurePagePath(userID string, p string, targetTitle string return nil, fmt.Errorf("could not find created page by ID: %w", err) } - // Save once - if err := t.saveTreeLocked(); err != nil { - return nil, fmt.Errorf("could not save tree: %w", err) - } - return &EnsurePathResult{ Exists: true, Page: page, @@ -978,7 +683,7 @@ func (t *TreeService) MoveNode(userID string, id string, parentID string) error } // Circular reference guard: node cannot be moved under its own descendants - if node.IsChildOf(newParent.ID, true) { + if node.HasDescendant(newParent.ID, true) { return fmt.Errorf("circular reference detected: %w", ErrMovePageCircularReference) } @@ -987,6 +692,7 @@ func (t *TreeService) MoveNode(userID string, id string, parentID string) error if err := t.store.ConvertNode(newParent, NodeKindSection); err != nil { return fmt.Errorf("could not auto-convert new parent page to section: %w", err) } + // Transitional in-memory update; authoritative kind comes from reloadProjectionLocked(). newParent.Kind = NodeKindSection } @@ -995,38 +701,33 @@ func (t *TreeService) MoveNode(userID string, id string, parentID string) error return fmt.Errorf("destination parent must be a section, got %q", newParent.Kind) } + oldParent := node.Parent + if oldParent == nil { + return fmt.Errorf("old parent not found: %w", ErrParentNotFound) + } + // Move on disk (strict by node.Kind inside NodeStore) if err := t.store.MoveNode(node, newParent); err != nil { return fmt.Errorf("could not move node on disk: %w", err) } - // Unlink from old parent in tree - oldParent := node.Parent - if oldParent == nil { - return fmt.Errorf("old parent not found: %w", ErrParentNotFound) + if err := t.store.RemoveChildOrder(oldParent, node.ID); err != nil { + return fmt.Errorf("could not update old parent order.json: %w", err) } - for i, e := range oldParent.Children { - if e.ID == id { - oldParent.Children = append(oldParent.Children[:i], oldParent.Children[i+1:]...) - break - } + if err := t.store.AppendChildOrder(newParent, node.ID); err != nil { + return fmt.Errorf("could not update new parent order.json: %w", err) } - - // Link under new parent - node.Position = len(newParent.Children) - newParent.Children = append(newParent.Children, node) + // Temporary parent update so WriteNodeFrontmatter resolves the new on-disk path. + // The authoritative projection is rebuilt immediately afterwards. node.Parent = newParent - - // Update metadata node.Metadata.UpdatedAt = time.Now().UTC() node.Metadata.LastAuthorID = userID - // Reindex positions - t.reindexPositions(newParent) - t.reindexPositions(oldParent) + if err := t.store.WriteNodeFrontmatter(node); err != nil { + return fmt.Errorf("could not write moved node frontmatter: %w", err) + } - // Persist tree - return t.saveTreeLocked() + return t.reloadProjectionLocked() } func (t *TreeService) SortPages(parentID string, orderedIDs []string) error { @@ -1048,16 +749,16 @@ func (t *TreeService) SortPages(parentID string, orderedIDs []string) error { } } - // Check if the number of orderedIDs is the same as the number of children - if len(orderedIDs) != len(parent.Children) { - return fmt.Errorf("number of ordered IDs does not match the number of children: %w", ErrInvalidSortOrder) + if parent.Kind != NodeKindSection { + return fmt.Errorf("cannot sort children of non-section parent %q", parent.Kind) } // Check if all IDs in the sort order are valid - existingIDs := make(map[string]bool) + existingIDs := make(map[string]bool, len(parent.Children)) for _, child := range parent.Children { existingIDs[child.ID] = true } + for _, id := range orderedIDs { if !existingIDs[id] { return fmt.Errorf("invalid ID in sort order, ID: %s - %w", id, ErrInvalidSortOrder) @@ -1072,34 +773,9 @@ func (t *TreeService) SortPages(parentID string, orderedIDs []string) error { seen[id] = true } - // Create a map to store the position of each page - positions := make(map[string]int) - for i, id := range orderedIDs { - positions[id] = i - } - - // Sort the children of the parent - sort.SliceStable(parent.Children, func(i, j int) bool { - return positions[parent.Children[i].ID] < positions[parent.Children[j].ID] - }) - - // write postion index to children - for i, child := range parent.Children { - child.Position = i + if err := t.store.WriteChildOrder(parent, orderedIDs); err != nil { + return fmt.Errorf("could not write order.json: %w", err) } - // Reindex the positions - t.reindexPositions(parent) - - // Save the tree - return t.saveTreeLocked() -} - -func (t *TreeService) reindexPositions(parent *PageNode) { - sort.SliceStable(parent.Children, func(i, j int) bool { - return parent.Children[i].Position < parent.Children[j].Position - }) - for i, child := range parent.Children { - child.Position = i - } + return t.reloadProjectionLocked() } diff --git a/internal/http/api/create_page.go b/internal/http/api/create_page.go index bad95e49..aa6ad393 100644 --- a/internal/http/api/create_page.go +++ b/internal/http/api/create_page.go @@ -33,7 +33,7 @@ func CreatePageHandler(w *wiki.Wiki) gin.HandlerFunc { if req.Kind != nil { kind = tree.NodeKind(*req.Kind) } - page, err := w.CreatePage(user.ID, req.ParentID, req.Title, req.Slug, &kind) + page, err := w.CreateNode(user.ID, req.ParentID, req.Title, req.Slug, &kind) if err != nil { respondWithError(c, err) return diff --git a/internal/http/api/helpers.go b/internal/http/api/helpers.go index 8cbadd67..b1cb6c1f 100644 --- a/internal/http/api/helpers.go +++ b/internal/http/api/helpers.go @@ -67,18 +67,7 @@ func buildPathFromNode(node *tree.PageNode) string { } return strings.Join(parts, "/") } - -func ToAPINode(node *tree.PageNode, parentPath string, userResolver *auth.UserResolver) *Node { - path := node.Slug - - if node.Slug == "root" { - path = "" - } - - if node.Slug != "root" && parentPath != "" { - path = parentPath + "/" + node.Slug - } - +func toAPINodeWithDerivedPosition(node *tree.PageNode, userResolver *auth.UserResolver, position int) *Node { var creator, lastAuthor *auth.UserLabel if userResolver != nil { creator, _ = userResolver.ResolveUserLabel(node.Metadata.CreatorID) @@ -86,12 +75,14 @@ func ToAPINode(node *tree.PageNode, parentPath string, userResolver *auth.UserRe } apiNode := &Node{ - ID: node.ID, - Title: node.Title, - Slug: node.Slug, - Path: path, - Position: node.Position, - Kind: node.Kind, + ID: node.ID, + Title: node.Title, + Slug: node.Slug, + Path: strings.Trim(node.CalculatePath(), "/"), + Position: position, + Kind: node.Kind, + RepairNeeded: node.RepairNeeded, + Issues: node.Issues, Metadata: NodeMetadata{ CreatedAt: node.Metadata.CreatedAt.Format(time.RFC3339), UpdatedAt: node.Metadata.UpdatedAt.Format(time.RFC3339), @@ -102,13 +93,18 @@ func ToAPINode(node *tree.PageNode, parentPath string, userResolver *auth.UserRe }, } - for _, child := range node.Children { - apiNode.Children = append(apiNode.Children, ToAPINode(child, path, userResolver)) + for i, child := range node.Children { + apiNode.Children = append(apiNode.Children, toAPINodeWithDerivedPosition(child, userResolver, i)) } return apiNode } +func ToAPINode(node *tree.PageNode, parentPath string, userResolver *auth.UserResolver) *Node { + _ = parentPath + return toAPINodeWithDerivedPosition(node, userResolver, 0) +} + // pruneNodeDepth limits the depth of the node tree to the specified depth. // depth == 0 -> keep the current node, drop all its children // depth > 0 -> recurse into children with depth-1 diff --git a/internal/http/api/node.go b/internal/http/api/node.go index f463f80c..1611a21f 100644 --- a/internal/http/api/node.go +++ b/internal/http/api/node.go @@ -16,10 +16,14 @@ type NodeMetadata struct { } type Node struct { - ID string `json:"id"` - Title string `json:"title"` - Slug string `json:"slug"` - Path string `json:"path"` + ID string `json:"id"` + Title string `json:"title"` + Slug string `json:"slug"` + Path string `json:"path"` + RepairNeeded bool `json:"repairNeeded,omitempty"` + Issues []tree.NodeIssue `json:"issues,omitempty"` + // Position is derived from the current sibling order in the read model. + // It is not canonical persisted state. Position int `json:"position"` Kind tree.NodeKind `json:"kind"` Children []*Node `json:"children"` diff --git a/internal/http/router_test.go b/internal/http/router_test.go index b9b47ecb..fad63daa 100644 --- a/internal/http/router_test.go +++ b/internal/http/router_test.go @@ -313,7 +313,7 @@ func TestDeletePageEndpoint(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - _, err := w.CreatePage("system", nil, "Delete Me", "delete-me", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Delete Me", "delete-me", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -346,11 +346,11 @@ func TestDeletePageEndpoint_HasChildren(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - parent, err := w.CreatePage("system", nil, "Parent", "parent", pageNodeKind()) + parent, err := w.CreateNode("system", nil, "Parent", "parent", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } - _, err = w.CreatePage("system", &parent.ID, "Child", "child", pageNodeKind()) + _, err = w.CreateNode("system", &parent.ID, "Child", "child", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -367,11 +367,11 @@ func TestDeletePageEndpoint_Recursive(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - parent, err := w.CreatePage("system", nil, "Parent", "parent", pageNodeKind()) + parent, err := w.CreateNode("system", nil, "Parent", "parent", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } - _, err = w.CreatePage("system", &parent.ID, "Child", "child", pageNodeKind()) + _, err = w.CreateNode("system", &parent.ID, "Child", "child", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -391,7 +391,7 @@ func TestUpdatePageEndpoint(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - _, err := w.CreatePage("system", nil, "Original Title", "original-title", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Original Title", "original-title", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -444,7 +444,7 @@ func TestUpdatePage_SlugRemainsIfUnchanged(t *testing.T) { router := createRouterTestInstance(w, t) // Create a page - created, err := w.CreatePage("system", nil, "Immutable Slug", "immutable-slug", pageNodeKind()) + created, err := w.CreateNode("system", nil, "Immutable Slug", "immutable-slug", pageNodeKind()) if err != nil { t.Fatalf("Failed to create page: %v", err) } @@ -478,13 +478,13 @@ func TestUpdatePage_PageAlreadyExists(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - _, err := w.CreatePage("system", nil, "Original Title", "original-title", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Original Title", "original-title", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } page := w.GetTree().Children[0] - _, err = w.CreatePage("system", nil, "Conflict Title", "conflict-title", pageNodeKind()) + _, err = w.CreateNode("system", nil, "Conflict Title", "conflict-title", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -547,7 +547,7 @@ func TestGetPageEndpoint(t *testing.T) { router := createRouterTestInstance(w, t) // Create a page - _, err := w.CreatePage("system", nil, "Welcome", "welcome", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Welcome", "welcome", pageNodeKind()) if err != nil { t.Fatalf("Failed to create page: %v", err) } @@ -633,7 +633,7 @@ func TestGetPageByPathEndpoint_PageReturnsNoChildren(t *testing.T) { router := createRouterTestInstance(w, t) // Create a standalone page (no children – adding children auto-converts it to a section) - _, err := w.CreatePage("system", nil, "My Page", "my-page", pageNodeKind()) + _, err := w.CreateNode("system", nil, "My Page", "my-page", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -666,15 +666,15 @@ func TestGetPageByPathEndpoint_SectionReturnsDirectChildrenOnly(t *testing.T) { sectionKind := tree.NodeKindSection // Create a section with a child page that itself has a grandchild - section, err := w.CreatePage("system", nil, "My Section", "my-section", §ionKind) + section, err := w.CreateNode("system", nil, "My Section", "my-section", §ionKind) if err != nil { t.Fatalf("CreatePage (section) failed: %v", err) } - child, err := w.CreatePage("system", §ion.ID, "Child Page", "child-page", pageNodeKind()) + child, err := w.CreateNode("system", §ion.ID, "Child Page", "child-page", pageNodeKind()) if err != nil { t.Fatalf("CreatePage (child) failed: %v", err) } - _, err = w.CreatePage("system", &child.ID, "Grandchild Page", "grandchild-page", pageNodeKind()) + _, err = w.CreateNode("system", &child.ID, "Grandchild Page", "grandchild-page", pageNodeKind()) if err != nil { t.Fatalf("CreatePage (grandchild) failed: %v", err) } @@ -712,11 +712,11 @@ func TestMovePageEndpoint(t *testing.T) { router := createRouterTestInstance(w, t) // Create two pages a and b - _, err := w.CreatePage("system", nil, "Section A", "section-a", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } - _, err = w.CreatePage("system", nil, "Section B", "section-b", pageNodeKind()) + _, err = w.CreateNode("system", nil, "Section B", "section-b", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -778,7 +778,7 @@ func TestMovePageEndpoint_ParentNotFound(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - _, err := w.CreatePage("system", nil, "Section A", "section-a", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -799,13 +799,13 @@ func TestMovePageEndpoint_CircularReference(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - _, err := w.CreatePage("system", nil, "Section A", "section-a", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } a := w.GetTree().Children[0] - _, err = w.CreatePage("system", &a.ID, "Section B", "section-b", pageNodeKind()) + _, err = w.CreateNode("system", &a.ID, "Section B", "section-b", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -824,19 +824,19 @@ func TestMovePage_FailsIfTargetAlreadyHasPageWithSameSlug(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - _, err := w.CreatePage("system", nil, "Section A", "section-a", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } a := w.GetTree().Children[0] - _, err = w.CreatePage("system", nil, "Section B", "section-b", pageNodeKind()) + _, err = w.CreateNode("system", nil, "Section B", "section-b", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } // Create Conflict Page in b - conflictPage, err := w.CreatePage("system", &a.ID, "Section B", "section-b", pageNodeKind()) + conflictPage, err := w.CreateNode("system", &a.ID, "Section B", "section-b", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -854,7 +854,7 @@ func TestMovePage_InTheSamePlace(t *testing.T) { defer test_utils.WrapCloseWithErrorCheck(w.Close, t) router := createRouterTestInstance(w, t) - _, err := w.CreatePage("system", nil, "Section A", "section-a", pageNodeKind()) + _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -873,15 +873,15 @@ func TestSortPagesEndpoint(t *testing.T) { router := createRouterTestInstance(w, t) // Create pages - page1, err := w.CreatePage("system", nil, "Page 1", "page-1", pageNodeKind()) + page1, err := w.CreateNode("system", nil, "Page 1", "page-1", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } - page2, err := w.CreatePage("system", nil, "Page 2", "page-2", pageNodeKind()) + page2, err := w.CreateNode("system", nil, "Page 2", "page-2", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } - page3, err := w.CreatePage("system", nil, "Page 3", "page-3", pageNodeKind()) + page3, err := w.CreateNode("system", nil, "Page 3", "page-3", pageNodeKind()) if err != nil { t.Fatalf("CreatePage failed: %v", err) } @@ -1452,7 +1452,7 @@ func TestAssetEndpoints(t *testing.T) { } // Step 1: Create page direkt über Wiki-API - page, err := w.CreatePage("system", nil, "Assets Page", "assets-page", pageNodeKind()) + page, err := w.CreateNode("system", nil, "Assets Page", "assets-page", pageNodeKind()) if err != nil { t.Fatalf("Failed to create page: %v", err) } @@ -1568,7 +1568,7 @@ func TestIndexingStatusEndpoint(t *testing.T) { // If needsAuth is true, it will obtain authentication cookies; otherwise it will get CSRF token only (for AuthDisabled mode). func uploadTestAsset(t *testing.T, router *gin.Engine, w *wiki.Wiki, content string, needsAuth bool) (assetURL string, cookies []*http.Cookie) { // Create a page - page, err := w.CreatePage("system", nil, "Test Page", "test-page", pageNodeKind()) + page, err := w.CreateNode("system", nil, "Test Page", "test-page", pageNodeKind()) if err != nil { t.Fatalf("Failed to create page: %v", err) } diff --git a/internal/links/helpers.go b/internal/links/helpers.go index 8f80174e..8c245a04 100644 --- a/internal/links/helpers.go +++ b/internal/links/helpers.go @@ -172,7 +172,7 @@ func toBacklinkResultItem(tree *tree.TreeService, backlink Backlink) BacklinkRes return BacklinkResultItem{} } - page, err := tree.FindPageByID(root.Children, backlink.FromPageID) + page, err := tree.FindPageByID(backlink.FromPageID) if err != nil { return BacklinkResultItem{} } @@ -215,7 +215,7 @@ func toOutgoingResultItem(tree *tree.TreeService, outgoing Outgoing) OutgoingRes return item } - toPage, err := tree.FindPageByID(root.Children, outgoing.ToPageID) + toPage, err := tree.FindPageByID(outgoing.ToPageID) if err != nil || toPage == nil { return item } diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go index 025bfd29..2a4bf495 100644 --- a/internal/wiki/wiki.go +++ b/internal/wiki/wiki.go @@ -186,7 +186,7 @@ func (w *Wiki) EnsureWelcomePage() error { return nil } k := tree.NodeKindPage - p, err := w.CreatePage(SYSTEM_USER_ID, nil, "Welcome to LeafWiki", "welcome-to-leafwiki", &k) + p, err := w.CreateNode(SYSTEM_USER_ID, nil, "Welcome to LeafWiki", "welcome-to-leafwiki", &k) if err != nil { return err } @@ -239,7 +239,7 @@ func (w *Wiki) TreeHash() string { return w.tree.TreeHash() } -func (w *Wiki) CreatePage(userID string, parentID *string, title string, slug string, kind *tree.NodeKind) (*tree.Page, error) { +func (w *Wiki) CreateNode(userID string, parentID *string, title string, slug string, kind *tree.NodeKind) (*tree.Page, error) { ve := errors.NewValidationErrors() if title == "" { @@ -265,25 +265,16 @@ func (w *Wiki) CreatePage(userID string, parentID *string, title string, slug st // Check if the parentID exists if parentID != nil && *parentID != "" { var err error - _, err = w.tree.FindPageByID(w.tree.GetTree().Children, *parentID) + _, err = w.tree.FindPageByID(*parentID) if err != nil { return nil, err } } var id *string - if *kind == tree.NodeKindPage { - var err error - id, err = w.tree.CreateNode(userID, parentID, title, slug, kind) - if err != nil { - return nil, err - } - } - if *kind == tree.NodeKindSection { - var err error - id, err = w.tree.CreateNode(userID, parentID, title, slug, kind) - if err != nil { - return nil, err - } + var err error + id, err = w.tree.CreateNode(userID, parentID, title, slug, kind) + if err != nil { + return nil, err } page, err := w.tree.GetPage(*id) @@ -367,7 +358,7 @@ func (w *Wiki) EnsurePath(userID string, targetPath string, targetTitle string, return page, nil } -func (w *Wiki) UpdatePage(userID string, id, title, slug string, content *string, kind *tree.NodeKind) (*tree.Page, error) { +func (w *Wiki) UpdatePage(userID string, id, title, slug string, content *string, _ *tree.NodeKind) (*tree.Page, error) { // Validate the request ve := errors.NewValidationErrors() @@ -524,7 +515,7 @@ func (w *Wiki) DeletePage(userID string, id string, recursive bool) error { if recursive { root := w.tree.GetTree() if root != nil { - node, err := w.tree.FindPageByID(root.Children, id) + node, err := w.tree.FindPageByID(id) if err == nil && node != nil { subtreeIDs = collectSubtreeIDs(node) oldPrefix = node.CalculatePath() // IMPORTANT: before delete @@ -609,7 +600,7 @@ func (w *Wiki) MovePage(userID, id, parentID string) error { root := w.tree.GetTree() if root != nil { - node, err := w.tree.FindPageByID(root.Children, id) + node, err := w.tree.FindPageByID(id) if err == nil && node != nil { oldPrefix = node.CalculatePath() subtreeIDs = collectSubtreeIDs(node) @@ -696,7 +687,7 @@ func (w *Wiki) SuggestSlug(parentID string, currentID string, title string) (str return w.slug.GenerateUniqueSlug(w.tree.GetTree(), currentID, title), nil } - parent, err := w.tree.FindPageByID(w.tree.GetTree().Children, parentID) + parent, err := w.tree.FindPageByID(parentID) if err != nil { return "", fmt.Errorf("parent not found: %w", err) } @@ -900,7 +891,7 @@ func (w *Wiki) GetUserByID(id string) (*auth.PublicUser, error) { } func (w *Wiki) UploadAsset(pageID string, file multipart.File, filename string) (string, error) { - page, err := w.tree.FindPageByID(w.tree.GetTree().Children, pageID) + page, err := w.tree.FindPageByID(pageID) if err != nil { return "", err } @@ -908,7 +899,7 @@ func (w *Wiki) UploadAsset(pageID string, file multipart.File, filename string) } func (w *Wiki) ListAssets(pageID string) ([]string, error) { - page, err := w.tree.FindPageByID(w.tree.GetTree().Children, pageID) + page, err := w.tree.FindPageByID(pageID) if err != nil { return nil, err } @@ -916,7 +907,7 @@ func (w *Wiki) ListAssets(pageID string) ([]string, error) { } func (w *Wiki) RenameAsset(pageID string, oldFilename, newFilename string) (string, error) { - page, err := w.tree.FindPageByID(w.tree.GetTree().Children, pageID) + page, err := w.tree.FindPageByID(pageID) if err != nil { return "", err } @@ -924,7 +915,7 @@ func (w *Wiki) RenameAsset(pageID string, oldFilename, newFilename string) (stri } func (w *Wiki) DeleteAsset(pageID string, filename string) error { - page, err := w.tree.FindPageByID(w.tree.GetTree().Children, pageID) + page, err := w.tree.FindPageByID(pageID) if err != nil { return err } From 9fdb156cfadd01762a1e69f9f1bb897cc9ce4159 Mon Sep 17 00:00:00 2001 From: Patrick Erber Date: Sun, 15 Mar 2026 17:59:45 +0100 Subject: [PATCH 4/7] feat: update tests --- .../core/tree/node_store_reconstruct_test.go | 357 ++-- internal/core/tree/node_store_test.go | 743 ++++---- internal/core/tree/schema.go | 51 - internal/core/tree/tree_service_test.go | 915 ++-------- internal/http/router_test.go | 112 +- internal/wiki/wiki_test.go | 1572 ++++++----------- 6 files changed, 1299 insertions(+), 2451 deletions(-) delete mode 100644 internal/core/tree/schema.go diff --git a/internal/core/tree/node_store_reconstruct_test.go b/internal/core/tree/node_store_reconstruct_test.go index 6cfa2e7a..d1ed3a6c 100644 --- a/internal/core/tree/node_store_reconstruct_test.go +++ b/internal/core/tree/node_store_reconstruct_test.go @@ -2,11 +2,9 @@ package tree import ( "path/filepath" - "sort" + "reflect" "strings" "testing" - - "github.com/perber/wiki/internal/core/markdown" ) func findChildBySlug(t *testing.T, parent *PageNode, slug string) *PageNode { @@ -20,7 +18,7 @@ func findChildBySlug(t *testing.T, parent *PageNode, slug string) *PageNode { return nil } -func slugs(children []*PageNode) []string { +func childSlugs(children []*PageNode) []string { out := make([]string, 0, len(children)) for _, c := range children { out = append(out, c.Slug) @@ -28,7 +26,53 @@ func slugs(children []*PageNode) []string { return out } -// --- tests --- +func assertRootNode(t *testing.T, tree *PageNode) { + t.Helper() + + if tree == nil { + t.Fatalf("expected root tree, got nil") + } + if tree.ID != "root" { + t.Fatalf("unexpected root id: got %q", tree.ID) + } + if tree.Kind != NodeKindSection { + t.Fatalf("unexpected root kind: got %q", tree.Kind) + } + if tree.Parent != nil { + t.Fatalf("expected root parent nil") + } +} + +func assertNode(t *testing.T, n *PageNode, wantID, wantTitle string, wantKind NodeKind) { + t.Helper() + + if n == nil { + t.Fatalf("expected node, got nil") + } + if n.ID != wantID { + t.Fatalf("unexpected id: want=%q got=%q", wantID, n.ID) + } + if n.Title != wantTitle { + t.Fatalf("unexpected title: want=%q got=%q", wantTitle, n.Title) + } + if n.Kind != wantKind { + t.Fatalf("unexpected kind: want=%q got=%q", wantKind, n.Kind) + } +} + +func assertSyntheticID(t *testing.T, n *PageNode) { + t.Helper() + + if n == nil { + t.Fatalf("expected node, got nil") + } + if strings.TrimSpace(n.ID) == "" { + t.Fatalf("expected non-empty id") + } + if !strings.HasPrefix(n.ID, "missing-id:") { + t.Fatalf("expected synthetic id with prefix %q, got %q", "missing-id:", n.ID) + } +} func TestNodeStore_ReconstructTreeFromFS_EmptyStorage_ReturnsRoot(t *testing.T) { tmp := t.TempDir() @@ -39,162 +83,161 @@ func TestNodeStore_ReconstructTreeFromFS_EmptyStorage_ReturnsRoot(t *testing.T) t.Fatalf("ReconstructTreeFromFS: %v", err) } - if tree == nil || tree.ID != "root" || tree.Kind != NodeKindSection { - t.Fatalf("unexpected root: %#v", tree) - } - if tree.Parent != nil { - t.Fatalf("expected root parent nil") - } + assertRootNode(t, tree) + if len(tree.Children) != 0 { t.Fatalf("expected root to have no children, got %d", len(tree.Children)) } } -func TestNodeStore_ReconstructTreeFromFS_BuildsSectionsAndPages_SkipsIndexMdAsPage(t *testing.T) { +func TestNodeStore_ReconstructTreeFromFS_BuildsSectionsAndPages(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - // FS layout: - // /docs/index.md (section content) - // /docs/intro.md (page) - // /readme.md (page at root) mustMkdir(t, filepath.Join(tmp, "root", "docs")) - secIndex := `--- + mustWriteFile(t, filepath.Join(tmp, "root", "docs", "index.md"), `--- leafwiki_id: sec-docs leafwiki_title: Documentation --- -# Section` - mustWriteFile(t, filepath.Join(tmp, "root", "docs", "index.md"), secIndex, 0o644) +# Section`, 0o644) - pageIntro := `--- + mustWriteFile(t, filepath.Join(tmp, "root", "docs", "intro.md"), `--- leafwiki_id: page-intro leafwiki_title: Introduction --- -# Intro` - mustWriteFile(t, filepath.Join(tmp, "root", "docs", "intro.md"), pageIntro, 0o644) +# Intro`, 0o644) - rootPage := `--- + mustWriteFile(t, filepath.Join(tmp, "root", "readme.md"), `--- leafwiki_id: page-readme leafwiki_title: Readme --- -# Readme` - mustWriteFile(t, filepath.Join(tmp, "root", "readme.md"), rootPage, 0o644) +# Readme`, 0o644) tree, err := store.ReconstructTreeFromFS() if err != nil { t.Fatalf("ReconstructTreeFromFS: %v", err) } - // root has: docs(section), readme(page) - docs := findChildBySlug(t, tree, "docs") - if docs.Kind != NodeKindSection { - t.Fatalf("expected docs to be section, got %q", docs.Kind) - } - // section title/id from index frontmatter - if docs.ID != "sec-docs" { - t.Fatalf("expected docs.ID=sec-docs, got %q", docs.ID) - } - if docs.Title != "Documentation" { - t.Fatalf("expected docs.Title=Documentation, got %q", docs.Title) + assertRootNode(t, tree) + + if got, want := childSlugs(tree.Children), []string{"docs", "readme"}; !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected root children: want=%v got=%v", want, got) } - // ensure index.md wasn't turned into a page child + docs := findChildBySlug(t, tree, "docs") + assertNode(t, docs, "sec-docs", "Documentation", NodeKindSection) + for _, ch := range docs.Children { if ch.Slug == "index" { - t.Fatalf("index.md must be skipped as page, but found slug index") + t.Fatalf("index.md must not appear as child page") } } intro := findChildBySlug(t, docs, "intro") - if intro.Kind != NodeKindPage { - t.Fatalf("expected intro to be page, got %q", intro.Kind) - } - // page title/id from frontmatter - if intro.ID != "page-intro" { - t.Fatalf("expected intro.ID=page-intro, got %q", intro.ID) - } - if intro.Title != "Introduction" { - t.Fatalf("expected intro.Title=Introduction, got %q", intro.Title) - } + assertNode(t, intro, "page-intro", "Introduction", NodeKindPage) readme := findChildBySlug(t, tree, "readme") - if readme.Kind != NodeKindPage { - t.Fatalf("expected readme to be page, got %q", readme.Kind) - } - if readme.ID != "page-readme" { - t.Fatalf("expected readme.ID=page-readme, got %q", readme.ID) - } - if readme.Title != "Readme" { - t.Fatalf("expected readme.Title=Readme, got %q", readme.Title) - } + assertNode(t, readme, "page-readme", "Readme", NodeKindPage) - // parent pointers if docs.Parent == nil || docs.Parent.ID != "root" { - t.Fatalf("expected docs parent root, got %#v", docs.Parent) + t.Fatalf("expected docs parent=root, got %#v", docs.Parent) } if intro.Parent == nil || intro.Parent.ID != docs.ID { - t.Fatalf("expected intro parent docs, got %#v", intro.Parent) + t.Fatalf("expected intro parent=%q, got %#v", docs.ID, intro.Parent) } } -func TestNodeStore_ReconstructTreeFromFS_SectionWithoutIndex_UsesDirNameAsTitle(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) - - // FS: /emptysec/ (no index.md) - mustMkdir(t, filepath.Join(tmp, "root", "emptysec")) - - tree, err := store.ReconstructTreeFromFS() - if err != nil { - t.Fatalf("ReconstructTreeFromFS: %v", err) - } - - sec := findChildBySlug(t, tree, "emptysec") - if sec.Kind != NodeKindSection { - t.Fatalf("expected section, got %q", sec.Kind) - } - // title defaults to folder name (per your code) - if sec.Title != "emptysec" { - t.Fatalf("expected title=emptysec, got %q", sec.Title) - } - if strings.TrimSpace(sec.ID) == "" { - t.Fatalf("expected some generated id, got empty") +func TestNodeStore_ReconstructTreeFromFS_FallbacksAndRepairFlags(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, root string) + slug string + wantTitle string + wantKind NodeKind + wantRepair bool + wantSyntheticID bool + }{ + { + name: "section without index uses directory name and is marked for repair", + setup: func(t *testing.T, root string) { + mustMkdir(t, filepath.Join(root, "emptysec")) + }, + slug: "emptysec", + wantTitle: "emptysec", + wantKind: NodeKindSection, + wantRepair: true, + wantSyntheticID: true, + }, + { + name: "page without frontmatter uses headline title and is marked for repair", + setup: func(t *testing.T, root string) { + mustWriteFile(t, filepath.Join(root, "plain.md"), "# hello\n", 0o644) + }, + slug: "plain", + wantTitle: "hello", + wantKind: NodeKindPage, + wantRepair: true, + wantSyntheticID: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tmp := t.TempDir() + store := NewNodeStore(tmp) + root := filepath.Join(tmp, "root") + + tc.setup(t, root) + + tree, err := store.ReconstructTreeFromFS() + if err != nil { + t.Fatalf("ReconstructTreeFromFS: %v", err) + } + + node := findChildBySlug(t, tree, tc.slug) + + if node.Title != tc.wantTitle { + t.Fatalf("unexpected title: want=%q got=%q", tc.wantTitle, node.Title) + } + if node.Kind != tc.wantKind { + t.Fatalf("unexpected kind: want=%q got=%q", tc.wantKind, node.Kind) + } + if node.RepairNeeded != tc.wantRepair { + t.Fatalf("unexpected repairNeeded: want=%v got=%v", tc.wantRepair, node.RepairNeeded) + } + + if tc.wantSyntheticID { + assertSyntheticID(t, node) + } + }) } } -func TestNodeStore_ReconstructTreeFromFS_PageWithoutFrontmatter_FallsBackToHeadlineTitle(t *testing.T) { +func TestNodeStore_ReconstructTreeFromFS_NormalizesSlugs(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - // FS: /plain.md (no fm) - mustWriteFile(t, filepath.Join(tmp, "root", "plain.md"), "# hello\n", 0o644) + mustWriteFile(t, filepath.Join(tmp, "root", "Valid Page.md"), "# Valid", 0o644) + mustWriteFile(t, filepath.Join(tmp, "root", "UPPERCASE.md"), "# Upper", 0o644) + mustMkdir(t, filepath.Join(tmp, "root", "Valid Section")) + mustWriteFile(t, filepath.Join(tmp, "root", "Valid Section", "index.md"), "# Section", 0o644) + mustWriteFile(t, filepath.Join(tmp, "root", "valid.md"), "# Valid", 0o644) tree, err := store.ReconstructTreeFromFS() if err != nil { t.Fatalf("ReconstructTreeFromFS: %v", err) } - p := findChildBySlug(t, tree, "plain") - if p.Kind != NodeKindPage { - t.Fatalf("expected page, got %q", p.Kind) - } - - // title fallback should be headline - if p.Title != "hello" { - t.Fatalf("expected title fallback to slug 'plain', got %q", p.Title) - } - if strings.TrimSpace(p.ID) == "" { - // should still have generated id (unless you later decide to keep empty) - t.Fatalf("expected generated id, got empty") + for _, slug := range []string{"valid", "valid-page", "uppercase", "valid-section"} { + findChildBySlug(t, tree, slug) } } -func TestNodeStore_ReconstructTreeFromFS_PositionsAreContiguous(t *testing.T) { +func TestNodeStore_ReconstructTreeFromFS_UsesDeterministicOrderWithoutOrderJSON(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - // Create several files/dirs mustWriteFile(t, filepath.Join(tmp, "root", "b.md"), "# b", 0o644) mustWriteFile(t, filepath.Join(tmp, "root", "a.md"), "# a", 0o644) mustMkdir(t, filepath.Join(tmp, "root", "zsec")) @@ -204,118 +247,10 @@ func TestNodeStore_ReconstructTreeFromFS_PositionsAreContiguous(t *testing.T) { t.Fatalf("ReconstructTreeFromFS: %v", err) } - // Positions should be 0..n-1 regardless of order - seen := make([]int, 0, len(tree.Children)) - for _, ch := range tree.Children { - seen = append(seen, ch.Position) - } - sort.Ints(seen) - for i := range seen { - if seen[i] != i { - t.Fatalf("expected contiguous positions 0..%d, got %v (slugs=%v)", len(seen)-1, seen, slugs(tree.Children)) - } - } -} - -func TestNodeStore_ReconstructTreeFromFS_WritesIDsBackToFiles(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) - - // Create files without leafwiki_id in frontmatter - mustWriteFile(t, filepath.Join(tmp, "root", "no-id.md"), "# No ID", 0o644) - mustMkdir(t, filepath.Join(tmp, "root", "section")) - mustWriteFile(t, filepath.Join(tmp, "root", "section", "index.md"), "# Section No ID", 0o644) - - // Run reconstruction - tree, err := store.ReconstructTreeFromFS() - if err != nil { - t.Fatalf("ReconstructTreeFromFS: %v", err) - } - - // Get the page and section nodes - page := findChildBySlug(t, tree, "no-id") - section := findChildBySlug(t, tree, "section") - - // Verify that IDs were generated - if page.ID == "" { - t.Fatalf("expected page to have generated ID, got empty") - } - if section.ID == "" { - t.Fatalf("expected section to have generated ID, got empty") - } - - // Now reload the files and check that IDs were written back - pageMd, err := markdown.LoadMarkdownFile(filepath.Join(tmp, "root", "no-id.md")) - if err != nil { - t.Fatalf("failed to reload page: %v", err) - } - if pageMd.GetFrontmatter().LeafWikiID != page.ID { - t.Fatalf("expected page frontmatter ID=%q, got %q", page.ID, pageMd.GetFrontmatter().LeafWikiID) - } - - sectionMd, err := markdown.LoadMarkdownFile(filepath.Join(tmp, "root", "section", "index.md")) - if err != nil { - t.Fatalf("failed to reload section index: %v", err) - } - if sectionMd.GetFrontmatter().LeafWikiID != section.ID { - t.Fatalf("expected section frontmatter ID=%q, got %q", section.ID, sectionMd.GetFrontmatter().LeafWikiID) - } - - // Run reconstruction again and verify IDs are stable (deterministic) - tree2, err := store.ReconstructTreeFromFS() - if err != nil { - t.Fatalf("second ReconstructTreeFromFS: %v", err) - } - - page2 := findChildBySlug(t, tree2, "no-id") - section2 := findChildBySlug(t, tree2, "section") - - if page2.ID != page.ID { - t.Fatalf("expected deterministic page ID on second run: first=%q, second=%q", page.ID, page2.ID) - } - if section2.ID != section.ID { - t.Fatalf("expected deterministic section ID on second run: first=%q, second=%q", section.ID, section2.ID) - } -} - -func TestNodeStore_ReconstructTreeFromFS_SkipsInvalidSlugs(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) - - // Create files and directories with invalid slug names - // Uppercase letters should be normalized - mustWriteFile(t, filepath.Join(tmp, "root", "Valid Page.md"), "# Valid", 0o644) - mustWriteFile(t, filepath.Join(tmp, "root", "UPPERCASE.md"), "# Upper", 0o644) - mustMkdir(t, filepath.Join(tmp, "root", "Valid Section")) - mustWriteFile(t, filepath.Join(tmp, "root", "Valid Section", "index.md"), "# Section", 0o644) - - // Create a valid file to ensure the test still works - mustWriteFile(t, filepath.Join(tmp, "root", "valid.md"), "# Valid", 0o644) - - tree, err := store.ReconstructTreeFromFS() - if err != nil { - t.Fatalf("ReconstructTreeFromFS: %v", err) - } - - // The valid file should be present with normalized slug - valid := findChildBySlug(t, tree, "valid") - if valid == nil { - t.Fatalf("expected valid page to be present") - } - - // Files with spaces and uppercase should be normalized - validPage := findChildBySlug(t, tree, "valid-page") - if validPage == nil { - t.Fatalf("expected 'Valid Page.md' to be normalized to 'valid-page'") - } - - uppercase := findChildBySlug(t, tree, "uppercase") - if uppercase == nil { - t.Fatalf("expected 'UPPERCASE.md' to be normalized to 'uppercase'") - } + got := childSlugs(tree.Children) + want := []string{"a", "b", "zsec"} - validSection := findChildBySlug(t, tree, "valid-section") - if validSection == nil { - t.Fatalf("expected 'Valid Section' directory to be normalized to 'valid-section'") + if !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected child order: want=%v got=%v", want, got) } } diff --git a/internal/core/tree/node_store_test.go b/internal/core/tree/node_store_test.go index acb02c32..cf6f30f9 100644 --- a/internal/core/tree/node_store_test.go +++ b/internal/core/tree/node_store_test.go @@ -28,126 +28,126 @@ func mustMkdir(t *testing.T, path string) { } } -func TestNodeStore_LoadTree_MissingFile_ReturnsDefaultRoot(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) - - tree, err := store.LoadTree("missing.json") +func mustRead(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) if err != nil { - t.Fatalf("LoadTree: %v", err) - } - if tree == nil { - t.Fatalf("expected tree, got nil") - } - if tree.ID != "root" || tree.Slug != "root" || tree.Title != "root" { - t.Fatalf("unexpected default root: %#v", tree) - } - if tree.Kind != NodeKindSection { - t.Fatalf("expected root kind %q, got %q", NodeKindSection, tree.Kind) - } - if tree.Parent != nil { - t.Fatalf("expected root parent nil") - } - if len(tree.Children) != 0 { - t.Fatalf("expected no children") + t.Fatalf("read %s: %v", path, err) } + return b } -func TestNodeStore_SaveTree_ThenLoadTree_AssignsParents(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) - - tree := &PageNode{ +func newRoot() *PageNode { + return &PageNode{ ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection, - Children: []*PageNode{ - { - ID: "s1", - Slug: "sec", - Title: "Section", - Kind: NodeKindSection, - Children: []*PageNode{ - { - ID: "p1", - Slug: "page", - Title: "Page", - Kind: NodeKindPage, - }, - }, - }, - }, - } - - if err := store.SaveTree("tree.json", tree); err != nil { - t.Fatalf("SaveTree: %v", err) - } - - loaded, err := store.LoadTree("tree.json") - if err != nil { - t.Fatalf("LoadTree: %v", err) } +} - sec := loaded.Children[0] - p := sec.Children[0] - - if sec.Parent == nil || sec.Parent.ID != "root" { - t.Fatalf("expected section parent root, got %#v", sec.Parent) - } - if p.Parent == nil || p.Parent.ID != "s1" { - t.Fatalf("expected page parent s1, got %#v", p.Parent) +func newSection(id, slug, title string, parent *PageNode) *PageNode { + return &PageNode{ + ID: id, + Slug: slug, + Title: title, + Kind: NodeKindSection, + Parent: parent, } } -func TestNodeStore_SaveTree_NilTree_Error(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) - - if err := store.SaveTree("tree.json", nil); err == nil { - t.Fatalf("expected error, got nil") +func newPage(id, slug, title string, parent *PageNode) *PageNode { + return &PageNode{ + ID: id, + Slug: slug, + Title: title, + Kind: NodeKindPage, + Parent: parent, } } -func TestNodeStore_CreateSection_CreatesFolder_NoIndexByDefault(t *testing.T) { +func TestNodeStore_CreateSection_CreatesFolderAndIndex(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - sec := &PageNode{ID: "sec1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + root := newRoot() + sec := newSection("sec1", "docs", "Docs", root) if err := store.CreateSection(root, sec); err != nil { t.Fatalf("CreateSection: %v", err) } - // expected folder: /root/docs dir := filepath.Join(tmp, "root", "docs") if st, err := os.Stat(dir); err != nil || !st.IsDir() { t.Fatalf("expected section folder at %s", dir) } - // no index.md by default index := filepath.Join(dir, "index.md") - if _, err := os.Stat(index); err == nil { - t.Fatalf("did not expect index.md to exist by default: %s", index) + raw, err := os.ReadFile(index) + if err != nil { + t.Fatalf("expected index.md to exist: %v", err) + } + + fm, body, has, err := markdown.ParseFrontmatter(string(raw)) + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !has { + t.Fatalf("expected frontmatter in index.md") + } + if fm.LeafWikiID != "sec1" { + t.Fatalf("expected leafwiki_id sec1, got %q", fm.LeafWikiID) + } + if fm.LeafWikiTitle != "Docs" { + t.Fatalf("expected leafwiki_title Docs, got %q", fm.LeafWikiTitle) + } + if !strings.Contains(body, "# Docs") { + t.Fatalf("expected H1 title in body, got %q", body) } } -func TestNodeStore_CreateSection_KindGuards(t *testing.T) { +func TestNodeStore_CreateSection_Guards(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - rootPageWrong := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindPage} - sec := &PageNode{ID: "sec1", Slug: "docs", Title: "Docs", Kind: NodeKindSection} - - if err := store.CreateSection(rootPageWrong, sec); err == nil { - t.Fatalf("expected error when parent is not a section") + tests := []struct { + name string + parent *PageNode + entry *PageNode + }{ + { + name: "parent must be section", + parent: &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindPage}, + entry: &PageNode{ID: "sec1", Slug: "docs", Title: "Docs", Kind: NodeKindSection}, + }, + { + name: "entry must be section", + parent: newRoot(), + entry: &PageNode{ID: "p1", Slug: "x", Title: "X", Kind: NodeKindPage}, + }, + { + name: "entry must not be nil", + parent: newRoot(), + entry: nil, + }, + { + name: "parent must not be nil", + parent: nil, + entry: &PageNode{ID: "sec1", Slug: "docs", Title: "Docs", Kind: NodeKindSection}, + }, + { + name: "entry must not be root", + parent: newRoot(), + entry: &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection}, + }, } - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - pageWrong := &PageNode{ID: "x", Slug: "x", Title: "X", Kind: NodeKindPage} - if err := store.CreateSection(root, pageWrong); err == nil { - t.Fatalf("expected error when new entry is not a section") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := store.CreateSection(tc.parent, tc.entry); err == nil { + t.Fatalf("expected error, got nil") + } + }) } } @@ -155,15 +155,15 @@ func TestNodeStore_CreatePage_CreatesMarkdownWithFrontmatter(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - page := &PageNode{ID: "p1", Slug: "hello", Title: "Hello World", Kind: NodeKindPage, Parent: root} + root := newRoot() + page := newPage("p1", "hello", "Hello World", root) if err := store.CreatePage(root, page); err != nil { t.Fatalf("CreatePage: %v", err) } - p := filepath.Join(tmp, "root", "hello.md") - raw, err := os.ReadFile(p) + path := filepath.Join(tmp, "root", "hello.md") + raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read created page: %v", err) } @@ -178,41 +178,83 @@ func TestNodeStore_CreatePage_CreatesMarkdownWithFrontmatter(t *testing.T) { if strings.TrimSpace(fm.LeafWikiID) != "p1" { t.Fatalf("expected leafwiki_id p1, got %q", fm.LeafWikiID) } - // CreatePage setzt nur ID im FM, Title kommt in den Body als H1 + if fm.LeafWikiTitle != "Hello World" { + t.Fatalf("expected leafwiki_title Hello World, got %q", fm.LeafWikiTitle) + } if !strings.Contains(body, "# Hello World") { t.Fatalf("expected H1 title in body, got: %q", body) } } -func TestNodeStore_CreatePage_RejectsCollision_FileOrDir(t *testing.T) { +func TestNodeStore_CreatePage_GuardsAndCollisions(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - - // collision as file - mustWriteFile(t, filepath.Join(tmp, "root", "dup.md"), "x", 0o644) - page := &PageNode{ID: "p1", Slug: "dup", Title: "Dup", Kind: NodeKindPage, Parent: root} - if err := store.CreatePage(root, page); err == nil { - t.Fatalf("expected PageAlreadyExistsError for existing file") + root := newRoot() + + tests := []struct { + name string + setup func(t *testing.T) + entry *PageNode + }{ + { + name: "entry must not be nil", + entry: nil, + }, + { + name: "entry must be page", + entry: newSection("s1", "docs", "Docs", root), + }, + { + name: "slug collides with file", + setup: func(t *testing.T) { + mustWriteFile(t, filepath.Join(tmp, "root", "dup.md"), "x", 0o644) + }, + entry: newPage("p1", "dup", "Dup", root), + }, + { + name: "slug collides with dir", + setup: func(t *testing.T) { + mustMkdir(t, filepath.Join(tmp, "root", "dupdir")) + }, + entry: newPage("p2", "dupdir", "DupDir", root), + }, } - // collision as dir - mustMkdir(t, filepath.Join(tmp, "root", "dupdir")) - page2 := &PageNode{ID: "p2", Slug: "dupdir", Title: "DupDir", Kind: NodeKindPage, Parent: root} - if err := store.CreatePage(root, page2); err == nil { - t.Fatalf("expected PageAlreadyExistsError for existing dir") + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.setup != nil { + tc.setup(t) + } + if err := store.CreatePage(root, tc.entry); err == nil { + t.Fatalf("expected error, got nil") + } + }) } + + t.Run("parent must be section", func(t *testing.T) { + parent := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindPage} + entry := newPage("p3", "page", "Page", parent) + + if err := store.CreatePage(parent, entry); err == nil { + t.Fatalf("expected error, got nil") + } + }) + + t.Run("parent must not be nil", func(t *testing.T) { + entry := &PageNode{ID: "p4", Slug: "page", Title: "Page", Kind: NodeKindPage} + if err := store.CreatePage(nil, entry); err == nil { + t.Fatalf("expected error, got nil") + } + }) } func TestNodeStore_UpsertContent_Page_CreatesOrUpdates_PreservesMode(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - page := &PageNode{ID: "p1", Slug: "p", Title: "My Page", Kind: NodeKindPage, Parent: root} + root := newRoot() + page := newPage("p1", "p", "My Page", root) - // create with custom mode path := filepath.Join(tmp, "root", "p.md") mustWriteFile(t, path, "# old", 0o600) @@ -224,26 +266,23 @@ func TestNodeStore_UpsertContent_Page_CreatesOrUpdates_PreservesMode(t *testing. if err != nil { t.Fatalf("stat: %v", err) } - // permissions should stay (best-effort; Windows behaves differently sometimes) - if runtime.GOOS != "windows" { - if st.Mode().Perm() != 0o600 { - t.Fatalf("expected perm 0600, got %o", st.Mode().Perm()) - } + if runtime.GOOS != "windows" && st.Mode().Perm() != 0o600 { + t.Fatalf("expected perm 0600, got %o", st.Mode().Perm()) } - raw, _ := os.ReadFile(path) - fm, body, has, err := markdown.ParseFrontmatter(string(raw)) + raw := string(mustRead(t, path)) + fm, body, has, err := markdown.ParseFrontmatter(raw) if err != nil { t.Fatalf("ParseFrontmatter: %v", err) } if !has { - t.Fatalf("expected FM to exist") + t.Fatalf("expected frontmatter") } if fm.LeafWikiID != "p1" { t.Fatalf("expected id p1, got %q", fm.LeafWikiID) } if fm.LeafWikiTitle != "My Page" { - t.Fatalf("expected title 'My Page', got %q", fm.LeafWikiTitle) + t.Fatalf("expected title My Page, got %q", fm.LeafWikiTitle) } if strings.TrimSpace(body) != "# new" { t.Fatalf("expected body '# new', got %q", body) @@ -254,8 +293,8 @@ func TestNodeStore_UpsertContent_Section_WritesIndexAndCreatesDir(t *testing.T) tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - sec := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + root := newRoot() + sec := newSection("s1", "docs", "Docs", root) if err := store.UpsertContent(sec, "# docs"); err != nil { t.Fatalf("UpsertContent: %v", err) @@ -271,12 +310,11 @@ func TestNodeStore_MoveNode_Page_MovesFileStrict(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - secA := &PageNode{ID: "a", Slug: "a", Title: "A", Kind: NodeKindSection, Parent: root} - secB := &PageNode{ID: "b", Slug: "b", Title: "B", Kind: NodeKindSection, Parent: root} - page := &PageNode{ID: "p1", Slug: "p", Title: "P", Kind: NodeKindPage, Parent: secA} + root := newRoot() + secA := newSection("a", "a", "A", root) + secB := newSection("b", "b", "B", root) + page := newPage("p1", "p", "P", secA) - // create source file at old location (tree-based path) src := filepath.Join(tmp, "root", "a", "p.md") mustWriteFile(t, src, "# hi", 0o644) @@ -297,26 +335,55 @@ func TestNodeStore_MoveNode_DriftWhenMissingSource(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - sec := &PageNode{ID: "s", Slug: "s", Title: "S", Kind: NodeKindSection, Parent: root} - page := &PageNode{ID: "p1", Slug: "p", Title: "P", Kind: NodeKindPage, Parent: sec} + root := newRoot() + sec := newSection("s", "s", "S", root) + page := newPage("p1", "p", "P", sec) err := store.MoveNode(page, root) if err == nil { t.Fatalf("expected DriftError, got nil") } + var de *DriftError if !errors.As(err, &de) { t.Fatalf("expected DriftError, got %T: %v", err, err) } } +func TestNodeStore_MoveNode_Guards(t *testing.T) { + tmp := t.TempDir() + store := NewNodeStore(tmp) + + root := newRoot() + pageParent := &PageNode{ID: "p", Slug: "p", Title: "P", Kind: NodeKindPage, Parent: root} + page := newPage("p1", "child", "Child", root) + + tests := []struct { + name string + entry *PageNode + parent *PageNode + }{ + {name: "entry required", entry: nil, parent: root}, + {name: "parent required", entry: page, parent: nil}, + {name: "cannot move root", entry: newRoot(), parent: root}, + {name: "parent must be section", entry: page, parent: pageParent}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := store.MoveNode(tc.entry, tc.parent); err == nil { + t.Fatalf("expected error, got nil") + } + }) + } +} + func TestNodeStore_DeletePage_RemovesFile_OrDriftIfMissing(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - page := &PageNode{ID: "p1", Slug: "p", Title: "P", Kind: NodeKindPage, Parent: root} + root := newRoot() + page := newPage("p1", "p", "P", root) path := filepath.Join(tmp, "root", "p.md") mustWriteFile(t, path, "# x", 0o644) @@ -328,19 +395,42 @@ func TestNodeStore_DeletePage_RemovesFile_OrDriftIfMissing(t *testing.T) { t.Fatalf("expected file deleted") } - // delete again -> drift err := store.DeletePage(page) if err == nil { t.Fatalf("expected DriftError") } } +func TestNodeStore_DeletePage_Guards(t *testing.T) { + tmp := t.TempDir() + store := NewNodeStore(tmp) + + root := newRoot() + + tests := []struct { + name string + entry *PageNode + }{ + {name: "entry required", entry: nil}, + {name: "cannot delete root", entry: newRoot()}, + {name: "entry must be page", entry: newSection("s1", "docs", "Docs", root)}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := store.DeletePage(tc.entry); err == nil { + t.Fatalf("expected error, got nil") + } + }) + } +} + func TestNodeStore_DeleteSection_RemovesFolderRecursive_OrDriftIfMissing(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - sec := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + root := newRoot() + sec := newSection("s1", "docs", "Docs", root) dir := filepath.Join(tmp, "root", "docs") mustMkdir(t, dir) @@ -360,35 +450,86 @@ func TestNodeStore_DeleteSection_RemovesFolderRecursive_OrDriftIfMissing(t *test } } -func TestNodeStore_RenameNode_PageAndSection(t *testing.T) { +func TestNodeStore_DeleteSection_Guards(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - - // page rename - page := &PageNode{ID: "p1", Slug: "old", Title: "P", Kind: NodeKindPage, Parent: root} - oldFile := filepath.Join(tmp, "root", "old.md") - mustWriteFile(t, oldFile, "# x", 0o644) + root := newRoot() - if err := store.RenameNode(page, "new"); err != nil { - t.Fatalf("RenameNode(page): %v", err) + tests := []struct { + name string + entry *PageNode + }{ + {name: "entry required", entry: nil}, + {name: "cannot delete root", entry: newRoot()}, + {name: "entry must be section", entry: newPage("p1", "p", "P", root)}, } - if _, err := os.Stat(filepath.Join(tmp, "root", "new.md")); err != nil { - t.Fatalf("expected new page file") + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := store.DeleteSection(tc.entry); err == nil { + t.Fatalf("expected error, got nil") + } + }) } +} + +func TestNodeStore_RenameNode_PageAndSection(t *testing.T) { + tmp := t.TempDir() + store := NewNodeStore(tmp) + + root := newRoot() + + t.Run("page", func(t *testing.T) { + page := newPage("p1", "old", "P", root) + oldFile := filepath.Join(tmp, "root", "old.md") + mustWriteFile(t, oldFile, "# x", 0o644) + + if err := store.RenameNode(page, "new"); err != nil { + t.Fatalf("RenameNode(page): %v", err) + } + if _, err := os.Stat(filepath.Join(tmp, "root", "new.md")); err != nil { + t.Fatalf("expected new page file") + } + }) + + t.Run("section", func(t *testing.T) { + sec := newSection("s1", "docs", "Docs", root) + secDir := filepath.Join(tmp, "root", "docs") + mustMkdir(t, secDir) + mustWriteFile(t, filepath.Join(secDir, "index.md"), "# y", 0o644) + + if err := store.RenameNode(sec, "docs2"); err != nil { + t.Fatalf("RenameNode(section): %v", err) + } + if st, err := os.Stat(filepath.Join(tmp, "root", "docs2")); err != nil || !st.IsDir() { + t.Fatalf("expected renamed section dir") + } + }) +} - // section rename - sec := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} - secDir := filepath.Join(tmp, "root", "docs") - mustMkdir(t, secDir) - mustWriteFile(t, filepath.Join(secDir, "index.md"), "# y", 0o644) +func TestNodeStore_RenameNode_Guards(t *testing.T) { + tmp := t.TempDir() + store := NewNodeStore(tmp) + + root := newRoot() - if err := store.RenameNode(sec, "docs2"); err != nil { - t.Fatalf("RenameNode(section): %v", err) + tests := []struct { + name string + entry *PageNode + newSlug string + }{ + {name: "entry required", entry: nil, newSlug: "x"}, + {name: "new slug required", entry: newPage("p1", "old", "P", root), newSlug: ""}, + {name: "cannot rename root", entry: newRoot(), newSlug: "x"}, } - if st, err := os.Stat(filepath.Join(tmp, "root", "docs2")); err != nil || !st.IsDir() { - t.Fatalf("expected renamed section dir") + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := store.RenameNode(tc.entry, tc.newSlug); err == nil { + t.Fatalf("expected error, got nil") + } + }) } } @@ -396,10 +537,9 @@ func TestNodeStore_ReadPageRaw_Section_NoIndex_ReturnsEmptyNil(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - sec := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + root := newRoot() + sec := newSection("s1", "docs", "Docs", root) - // folder exists, but no index.md mustMkdir(t, filepath.Join(tmp, "root", "docs")) raw, err := store.ReadPageRaw(sec) @@ -415,8 +555,8 @@ func TestNodeStore_ReadPageRaw_Page_Missing_IsDrift(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - page := &PageNode{ID: "p1", Slug: "p", Title: "P", Kind: NodeKindPage, Parent: root} + root := newRoot() + page := newPage("p1", "p", "P", root) _, err := store.ReadPageRaw(page) if err == nil { @@ -424,118 +564,96 @@ func TestNodeStore_ReadPageRaw_Page_Missing_IsDrift(t *testing.T) { } } -func TestNodeStore_SyncFrontmatterIfExists_Page_UpdatesOrAddsFM(t *testing.T) { +func TestNodeStore_WriteNodeFrontmatter_PageAndSection(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - page := &PageNode{ID: "p1", Slug: "p", Title: "Title A", Kind: NodeKindPage, Parent: root} + root := newRoot() - path := filepath.Join(tmp, "root", "p.md") - - // file without FM - mustWriteFile(t, path, "# Body\nHello", 0o644) - - if err := store.SyncFrontmatterIfExists(page); err != nil { - t.Fatalf("SyncFrontmatterIfExists: %v", err) - } + t.Run("page", func(t *testing.T) { + page := newPage("p1", "p", "Title A", root) + path := filepath.Join(tmp, "root", "p.md") + mustWriteFile(t, path, "# Body\nHello", 0o644) - raw := string(mustRead(t, path)) - fm, body, has, err := markdown.ParseFrontmatter(raw) - if err != nil { - t.Fatalf("ParseFrontmatter: %v", err) - } - if !has { - t.Fatalf("expected fm after sync") - } - if fm.LeafWikiID != "p1" || fm.LeafWikiTitle != "Title A" { - t.Fatalf("unexpected fm: %#v", fm) - } - if strings.TrimSpace(body) != "# Body\nHello" { - t.Fatalf("body changed unexpectedly: %q", body) - } + if err := store.WriteNodeFrontmatter(page); err != nil { + t.Fatalf("WriteNodeFrontmatter(page): %v", err) + } - // update title and id - page.Title = "Title B" - page.ID = "p1b" - if err := store.SyncFrontmatterIfExists(page); err != nil { - t.Fatalf("SyncFrontmatterIfExists(update): %v", err) - } - raw2 := string(mustRead(t, path)) - fm2, body2, has2, err := markdown.ParseFrontmatter(raw2) - if err != nil { - t.Fatalf("ParseFrontmatter: %v", err) - } - if !has2 || fm2.LeafWikiID != "p1b" || fm2.LeafWikiTitle != "Title B" { - t.Fatalf("expected updated fm, got %#v", fm2) - } - if strings.TrimSpace(body2) != "# Body\nHello" { - t.Fatalf("body changed unexpectedly on update: %q", body2) - } -} + raw := string(mustRead(t, path)) + fm, body, has, err := markdown.ParseFrontmatter(raw) + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !has { + t.Fatalf("expected frontmatter") + } + if fm.LeafWikiID != "p1" || fm.LeafWikiTitle != "Title A" { + t.Fatalf("unexpected frontmatter: %#v", fm) + } + if strings.TrimSpace(body) != "# Body\nHello" { + t.Fatalf("body changed unexpectedly: %q", body) + } + }) -func TestNodeStore_SyncFrontmatterIfExists_Section_NoIndex_NoSideEffects(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) + t.Run("section creates index when missing", func(t *testing.T) { + sec := newSection("s1", "docs", "Docs", root) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - sec := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + if err := store.WriteNodeFrontmatter(sec); err != nil { + t.Fatalf("WriteNodeFrontmatter(section): %v", err) + } - // Do NOT create folder: sync must not mkdir via write-path; should return nil. - if err := store.SyncFrontmatterIfExists(sec); err != nil { - t.Fatalf("SyncFrontmatterIfExists(section): %v", err) - } - // Ensure no folder created implicitly - if _, err := os.Stat(filepath.Join(tmp, "root", "docs")); err == nil { - t.Fatalf("expected no side effects (folder created), but folder exists") - } + index := filepath.Join(tmp, "root", "docs", "index.md") + raw := string(mustRead(t, index)) + fm, _, has, err := markdown.ParseFrontmatter(raw) + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !has { + t.Fatalf("expected frontmatter") + } + if fm.LeafWikiID != "s1" || fm.LeafWikiTitle != "Docs" { + t.Fatalf("unexpected frontmatter: %#v", fm) + } + }) } -func TestNodeStore_resolveNode_FileVsFolder(t *testing.T) { +func TestNodeStore_WriteNodeFrontmatter_GuardsAndDrift(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - - page := &PageNode{ID: "p1", Slug: "p", Title: "P", Kind: NodeKindPage, Parent: root} - mustWriteFile(t, filepath.Join(tmp, "root", "p.md"), "# x", 0o644) - - r1, err := store.resolveNode(page) - if err != nil { - t.Fatalf("resolveNode(page): %v", err) - } - if r1.Kind != NodeKindPage || !r1.HasContent || !strings.HasSuffix(r1.FilePath, "p.md") { - t.Fatalf("unexpected resolved: %#v", r1) - } + root := newRoot() + page := newPage("p1", "p", "P", root) - sec := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} - secDir := filepath.Join(tmp, "root", "docs") - mustMkdir(t, secDir) + t.Run("entry required", func(t *testing.T) { + if err := store.WriteNodeFrontmatter(nil); err == nil { + t.Fatalf("expected error, got nil") + } + }) - r2, err := store.resolveNode(sec) - if err != nil { - t.Fatalf("resolveNode(sec without index): %v", err) - } - if r2.Kind != NodeKindSection || r2.HasContent { - t.Fatalf("expected section without content: %#v", r2) - } + t.Run("root is noop", func(t *testing.T) { + if err := store.WriteNodeFrontmatter(newRoot()); err != nil { + t.Fatalf("expected nil, got %v", err) + } + }) - mustWriteFile(t, filepath.Join(secDir, "index.md"), "# idx", 0o644) - r3, err := store.resolveNode(sec) - if err != nil { - t.Fatalf("resolveNode(sec with index): %v", err) - } - if r3.Kind != NodeKindSection || !r3.HasContent || !strings.HasSuffix(r3.FilePath, "index.md") { - t.Fatalf("unexpected resolved: %#v", r3) - } + t.Run("page missing file is drift", func(t *testing.T) { + err := store.WriteNodeFrontmatter(page) + if err == nil { + t.Fatalf("expected DriftError") + } + var de *DriftError + if !errors.As(err, &de) { + t.Fatalf("expected DriftError, got %T: %v", err, err) + } + }) } func TestNodeStore_ConvertNode_PageToSection_MovesToIndex(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - entry := &PageNode{ID: "p1", Slug: "p", Title: "P", Kind: NodeKindPage, Parent: root} + root := newRoot() + entry := newPage("p1", "p", "P", root) file := filepath.Join(tmp, "root", "p.md") mustWriteFile(t, file, "# hi", 0o644) @@ -553,86 +671,99 @@ func TestNodeStore_ConvertNode_PageToSection_MovesToIndex(t *testing.T) { } } -func TestNodeStore_ConvertNode_SectionToPage_RejectsNonEmptyFolder(t *testing.T) { +func TestNodeStore_ConvertNode_SectionToPage(t *testing.T) { tmp := t.TempDir() store := NewNodeStore(tmp) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - entry := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + root := newRoot() - dir := filepath.Join(tmp, "root", "docs") - mustMkdir(t, dir) - mustWriteFile(t, filepath.Join(dir, "index.md"), "# idx", 0o644) - mustWriteFile(t, filepath.Join(dir, "other.txt"), "nope", 0o644) + t.Run("rejects non-empty folder", func(t *testing.T) { + entry := newSection("s1", "docs", "Docs", root) - err := store.ConvertNode(entry, NodeKindPage) - if err == nil { - t.Fatalf("expected ConvertNotAllowedError") - } - var cna *ConvertNotAllowedError - if !errors.As(err, &cna) { - t.Fatalf("expected ConvertNotAllowedError, got %T: %v", err, err) - } -} + dir := filepath.Join(tmp, "root", "docs") + mustMkdir(t, dir) + mustWriteFile(t, filepath.Join(dir, "index.md"), "# idx", 0o644) + mustWriteFile(t, filepath.Join(dir, "other.txt"), "nope", 0o644) -func TestNodeStore_ConvertNode_SectionToPage_WithIndex_MovesAndRemovesFolder(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) + err := store.ConvertNode(entry, NodeKindPage) + if err == nil { + t.Fatalf("expected ConvertNotAllowedError") + } + var cna *ConvertNotAllowedError + if !errors.As(err, &cna) { + t.Fatalf("expected ConvertNotAllowedError, got %T: %v", err, err) + } + }) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - entry := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + t.Run("with index moves and removes folder", func(t *testing.T) { + tmp2 := t.TempDir() + store2 := NewNodeStore(tmp2) + root2 := newRoot() + entry := newSection("s1", "docs", "Docs", root2) - dir := filepath.Join(tmp, "root", "docs") - mustMkdir(t, dir) - mustWriteFile(t, filepath.Join(dir, "index.md"), "# idx", 0o644) + dir := filepath.Join(tmp2, "root", "docs") + mustMkdir(t, dir) + mustWriteFile(t, filepath.Join(dir, "index.md"), "# idx", 0o644) - if err := store.ConvertNode(entry, NodeKindPage); err != nil { - t.Fatalf("ConvertNode(section->page): %v", err) - } + if err := store2.ConvertNode(entry, NodeKindPage); err != nil { + t.Fatalf("ConvertNode(section->page): %v", err) + } - pageFile := filepath.Join(tmp, "root", "docs.md") - if _, err := os.Stat(pageFile); err != nil { - t.Fatalf("expected page file: %v", err) - } - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Fatalf("expected folder removed") - } -} + pageFile := filepath.Join(tmp2, "root", "docs.md") + if _, err := os.Stat(pageFile); err != nil { + t.Fatalf("expected page file: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expected folder removed") + } + }) -func TestNodeStore_ConvertNode_SectionToPage_NoIndex_CreatesEmptyPageWithFM(t *testing.T) { - tmp := t.TempDir() - store := NewNodeStore(tmp) + t.Run("no index creates empty page with frontmatter", func(t *testing.T) { + tmp2 := t.TempDir() + store2 := NewNodeStore(tmp2) + root2 := newRoot() + entry := newSection("s1", "docs", "Docs", root2) - root := &PageNode{ID: "root", Slug: "root", Title: "root", Kind: NodeKindSection} - entry := &PageNode{ID: "s1", Slug: "docs", Title: "Docs", Kind: NodeKindSection, Parent: root} + dir := filepath.Join(tmp2, "root", "docs") + mustMkdir(t, dir) - dir := filepath.Join(tmp, "root", "docs") - mustMkdir(t, dir) - // empty folder, no index.md + if err := store2.ConvertNode(entry, NodeKindPage); err != nil { + t.Fatalf("ConvertNode(section->page no index): %v", err) + } - if err := store.ConvertNode(entry, NodeKindPage); err != nil { - t.Fatalf("ConvertNode(section->page no index): %v", err) - } + pageFile := filepath.Join(tmp2, "root", "docs.md") + raw := string(mustRead(t, pageFile)) + fm, _, has, err := markdown.ParseFrontmatter(raw) + if err != nil { + t.Fatalf("ParseFrontmatter: %v", err) + } + if !has || fm.LeafWikiID != "s1" || fm.LeafWikiTitle != "Docs" { + t.Fatalf("unexpected fm: %#v", fm) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("expected folder removed") + } + }) +} - pageFile := filepath.Join(tmp, "root", "docs.md") - raw := string(mustRead(t, pageFile)) - fm, _, has, err := markdown.ParseFrontmatter(raw) - if err != nil { - t.Fatalf("ParseFrontmatter: %v", err) - } - if !has || fm.LeafWikiID != "s1" || fm.LeafWikiTitle != "Docs" { - t.Fatalf("unexpected fm: %#v", fm) - } - if _, err := os.Stat(dir); !os.IsNotExist(err) { - t.Fatalf("expected folder removed") +func TestNodeStore_ConvertNode_Guards(t *testing.T) { + tmp := t.TempDir() + store := NewNodeStore(tmp) + + tests := []struct { + name string + entry *PageNode + target NodeKind + }{ + {name: "entry required", entry: nil, target: NodeKindPage}, + {name: "unknown target kind", entry: newRoot(), target: NodeKind("weird")}, } -} -func mustRead(t *testing.T, path string) []byte { - t.Helper() - b, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read %s: %v", path, err) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := store.ConvertNode(tc.entry, tc.target); err == nil { + t.Fatalf("expected error, got nil") + } + }) } - return b } diff --git a/internal/core/tree/schema.go b/internal/core/tree/schema.go deleted file mode 100644 index 42fdd8d3..00000000 --- a/internal/core/tree/schema.go +++ /dev/null @@ -1,51 +0,0 @@ -package tree - -import ( - "encoding/json" - "log" - "os" - "path/filepath" -) - -const CurrentSchemaVersion = 2 - -type SchemaInfo struct { - Version int `json:"version"` -} - -func loadSchema(storageDir string) (SchemaInfo, error) { - path := filepath.Join(storageDir, "schema.json") - - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - // First run / legacy install - log.Printf("Schema file not found, assuming version 0") - return SchemaInfo{Version: 0}, nil - } - log.Printf("Error reading schema file: %v", err) - return SchemaInfo{}, err - } - - var s SchemaInfo - if err := json.Unmarshal(data, &s); err != nil { - log.Printf("Error unmarshaling schema file: %v", err) - return SchemaInfo{}, err - } - - return s, nil -} - -func saveSchema(storageDir string, version int) error { - path := filepath.Join(storageDir, "schema.json") - s := SchemaInfo{Version: version} - - data, err := json.MarshalIndent(s, "", " ") - if err != nil { - log.Printf("Error marshaling schema data: %v", err) - return err - } - - log.Printf("Saving schema version %d to %s", version, path) - return os.WriteFile(path, data, 0o644) -} diff --git a/internal/core/tree/tree_service_test.go b/internal/core/tree/tree_service_test.go index 384a99ad..da8a4795 100644 --- a/internal/core/tree/tree_service_test.go +++ b/internal/core/tree/tree_service_test.go @@ -14,31 +14,31 @@ import ( func newLoadedService(t *testing.T) (*TreeService, string) { t.Helper() - tmpDir := t.TempDir() - - // Ensure schema is current so LoadTree doesn't try to migrate unless a test wants it. - if err := saveSchema(tmpDir, CurrentSchemaVersion); err != nil { - t.Fatalf("saveSchema failed: %v", err) - } + tmpDir := t.TempDir() svc := NewTreeService(tmpDir) + if err := svc.LoadTree(); err != nil { t.Fatalf("LoadTree failed: %v", err) } + return svc, tmpDir } func mustStat(t *testing.T, path string) os.FileInfo { t.Helper() + info, err := os.Stat(path) if err != nil { t.Fatalf("expected %q to exist, stat error: %v", path, err) } + return info } func mustNotExist(t *testing.T, path string) { t.Helper() + _, err := os.Stat(path) if err == nil { t.Fatalf("expected %q to not exist, but it exists", path) @@ -48,20 +48,12 @@ func mustNotExist(t *testing.T, path string) { } } -// --- A) Load/Save basics --- - -func TestTreeService_LoadTree_DefaultRootWhenMissing(t *testing.T) { - tmpDir := t.TempDir() +func ptrKind(k NodeKind) *NodeKind { return &k } - // schema current to prevent migration from failing due to missing schema file - if err := saveSchema(tmpDir, CurrentSchemaVersion); err != nil { - t.Fatalf("saveSchema failed: %v", err) - } +// --- load basics --- - svc := NewTreeService(tmpDir) - if err := svc.LoadTree(); err != nil { - t.Fatalf("LoadTree failed: %v", err) - } +func TestTreeService_LoadTree_DefaultRootWhenMissing(t *testing.T) { + svc, _ := newLoadedService(t) tree := svc.GetTree() if tree == nil || tree.ID != "root" { @@ -70,54 +62,17 @@ func TestTreeService_LoadTree_DefaultRootWhenMissing(t *testing.T) { if tree.Kind != NodeKindSection { t.Fatalf("expected root to be section, got %q", tree.Kind) } -} - -func TestTreeService_SaveAndLoad_RoundtripParents(t *testing.T) { - svc, tmpDir := newLoadedService(t) - - // Create a small tree through public API (exercises disk + tree) - idA, err := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode A failed: %v", err) - } - _, err = svc.CreateNode("system", idA, "B", "b", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode B failed: %v", err) + if tree.Parent != nil { + t.Fatalf("expected root parent nil") } - - if err := svc.SaveTree(); err != nil { - t.Fatalf("SaveTree failed: %v", err) - } - - // Reload in a new service instance - if err := saveSchema(tmpDir, CurrentSchemaVersion); err != nil { - t.Fatalf("saveSchema failed: %v", err) - } - loaded := NewTreeService(tmpDir) - if err := loaded.LoadTree(); err != nil { - t.Fatalf("LoadTree failed: %v", err) - } - - root := loaded.GetTree() - if len(root.Children) != 1 { - t.Fatalf("expected 1 child at root, got %d", len(root.Children)) - } - a := root.Children[0] - if a.Parent == nil || a.Parent.ID != "root" { - t.Fatalf("expected parent pointer on A") - } - if len(a.Children) != 1 { - t.Fatalf("expected A to have 1 child, got %d", len(a.Children)) - } - b := a.Children[0] - if b.Parent == nil || b.Parent.ID != a.ID { - t.Fatalf("expected parent pointer on B") + if len(tree.Children) != 0 { + t.Fatalf("expected root to have no children") } } -// --- B) Create/Update/Delete disk sync --- +// --- create / update / delete --- -func TestTreeService_CreateNode_Page_Root_CreatesFileAndFrontmatter(t *testing.T) { +func TestTreeService_CreateNode_PageAtRoot_CreatesFileAndFrontmatter(t *testing.T) { svc, tmpDir := newLoadedService(t) id, err := svc.CreateNode("system", nil, "Welcome", "welcome", ptrKind(NodeKindPage)) @@ -125,11 +80,10 @@ func TestTreeService_CreateNode_Page_Root_CreatesFileAndFrontmatter(t *testing.T t.Fatalf("CreateNode failed: %v", err) } - // file path: /root/welcome.md (based on your existing tests + GeneratePath convention) - p := filepath.Join(tmpDir, "root", "welcome.md") - mustStat(t, p) + path := filepath.Join(tmpDir, "root", "welcome.md") + mustStat(t, path) - raw, err := os.ReadFile(p) + raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read file: %v", err) } @@ -139,47 +93,39 @@ func TestTreeService_CreateNode_Page_Root_CreatesFileAndFrontmatter(t *testing.T t.Fatalf("ParseFrontmatter: %v", err) } if !has { - t.Fatalf("expected frontmatter to exist") + t.Fatalf("expected frontmatter") } if strings.TrimSpace(fm.LeafWikiID) != *id { t.Fatalf("expected leafwiki_id=%q, got %q", *id, fm.LeafWikiID) } + if fm.LeafWikiTitle != "Welcome" { + t.Fatalf("expected leafwiki_title=Welcome, got %q", fm.LeafWikiTitle) + } } func TestTreeService_CreateChild_UnderPage_AutoConvertsParentToSection(t *testing.T) { svc, tmpDir := newLoadedService(t) - // Create parent as page parentID, err := svc.CreateNode("system", nil, "Docs", "docs", ptrKind(NodeKindPage)) if err != nil { t.Fatalf("Create parent failed: %v", err) } - // Should exist as file initially parentFile := filepath.Join(tmpDir, "root", "docs.md") mustStat(t, parentFile) - // Create child under parent: must convert parent to section _, err = svc.CreateNode("system", parentID, "Getting Started", "getting-started", ptrKind(NodeKindPage)) if err != nil { t.Fatalf("Create child failed: %v", err) } - // Parent should now be a folder with index.md (converted from docs.md) parentDir := filepath.Join(tmpDir, "root", "docs") mustStat(t, parentDir) - index := filepath.Join(parentDir, "index.md") - mustStat(t, index) - - // Old file should be gone + mustStat(t, filepath.Join(parentDir, "index.md")) mustNotExist(t, parentFile) + mustStat(t, filepath.Join(parentDir, "getting-started.md")) - // Child file should be inside folder - childFile := filepath.Join(parentDir, "getting-started.md") - mustStat(t, childFile) - - // Tree kind updated - parentNode, err := svc.FindPageByID(svc.GetTree().Children, *parentID) + parentNode, err := svc.FindPageByID(*parentID) if err != nil { t.Fatalf("FindPageByID: %v", err) } @@ -188,7 +134,7 @@ func TestTreeService_CreateChild_UnderPage_AutoConvertsParentToSection(t *testin } } -func TestTreeService_UpdateNode_TitleOnly_SyncsFrontmatterIfFileExists(t *testing.T) { +func TestTreeService_UpdateNode_TitleOnly_UpdatesFrontmatter(t *testing.T) { svc, tmpDir := newLoadedService(t) id, err := svc.CreateNode("system", nil, "Docs", "docs", ptrKind(NodeKindPage)) @@ -196,18 +142,18 @@ func TestTreeService_UpdateNode_TitleOnly_SyncsFrontmatterIfFileExists(t *testin t.Fatalf("CreateNode failed: %v", err) } - p := filepath.Join(tmpDir, "root", "docs.md") - mustStat(t, p) + path := filepath.Join(tmpDir, "root", "docs.md") + mustStat(t, path) - // Update title only: content=nil, slug unchanged if err := svc.UpdateNode("system", *id, "Documentation", "docs", nil); err != nil { t.Fatalf("UpdateNode failed: %v", err) } - raw, err := os.ReadFile(p) + raw, err := os.ReadFile(path) if err != nil { t.Fatalf("read: %v", err) } + fm, _, has, err := markdown.ParseFrontmatter(string(raw)) if err != nil { t.Fatalf("ParseFrontmatter: %v", err) @@ -216,11 +162,11 @@ func TestTreeService_UpdateNode_TitleOnly_SyncsFrontmatterIfFileExists(t *testin t.Fatalf("expected frontmatter") } if fm.LeafWikiTitle != "Documentation" { - t.Fatalf("expected leafwiki_title to be updated, got %q", fm.LeafWikiTitle) + t.Fatalf("expected updated title, got %q", fm.LeafWikiTitle) } } -func TestTreeService_UpdateNode_SlugRename_RenamesOnDisk(t *testing.T) { +func TestTreeService_UpdateNode_RenameSlug_RenamesOnDisk(t *testing.T) { svc, tmpDir := newLoadedService(t) id, err := svc.CreateNode("system", nil, "Docs", "docs", ptrKind(NodeKindPage)) @@ -231,357 +177,181 @@ func TestTreeService_UpdateNode_SlugRename_RenamesOnDisk(t *testing.T) { oldPath := filepath.Join(tmpDir, "root", "docs.md") mustStat(t, oldPath) - newSlug := "documentation" - if err := svc.UpdateNode("system", *id, "Docs", newSlug, nil); err != nil { + if err := svc.UpdateNode("system", *id, "Docs", "documentation", nil); err != nil { t.Fatalf("UpdateNode failed: %v", err) } - newPath := filepath.Join(tmpDir, "root", newSlug+".md") - mustStat(t, newPath) + mustStat(t, filepath.Join(tmpDir, "root", "documentation.md")) mustNotExist(t, oldPath) } -/* -Disable this test for now as we are not enforcing to pass the kinds yet. -func TestTreeService_UpdateNode_SectionToPage_DisallowedWithChildren(t *testing.T) { - svc, _ := newLoadedService(t) - - // Create parent page, then child to force parent to section - parentID, err := svc.CreateNode("system", nil, "Docs", "docs", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("Create parent failed: %v", err) - } - _, err = svc.CreateNode("system", parentID, "Child", "child", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("Create child failed: %v", err) - } - - // Now parent is section with children, attempt to convert back to page - err = svc.UpdateNode("system", *parentID, "Docs", "docs", nil) - if err == nil { - t.Fatalf("expected error converting section->page with children") - } - if !errors.Is(err, ErrPageHasChildren) { - t.Fatalf("expected ErrPageHasChildren, got: %v", err) - } -} -*/ - -func TestTreeService_DeleteNode_NonRecursiveErrorsWhenHasChildren(t *testing.T) { - svc, _ := newLoadedService(t) - - parentID, _ := svc.CreateNode("system", nil, "Parent", "parent", ptrKind(NodeKindPage)) - _, _ = svc.CreateNode("system", parentID, "Child", "child", ptrKind(NodeKindPage)) - - err := svc.DeleteNode("system", *parentID, false) - if err == nil { - t.Fatalf("expected error") - } - if !errors.Is(err, ErrPageHasChildren) { - t.Fatalf("expected ErrPageHasChildren, got: %v", err) - } -} - -func TestTreeService_DeleteNode_RecursiveDeletesDiskAndTree(t *testing.T) { - svc, tmpDir := newLoadedService(t) - - parentID, _ := svc.CreateNode("system", nil, "Parent", "parent", ptrKind(NodeKindPage)) - _, _ = svc.CreateNode("system", parentID, "Child", "child", ptrKind(NodeKindPage)) - - // Parent should now be a folder - parentDir := filepath.Join(tmpDir, "root", "parent") - mustStat(t, parentDir) - - err := svc.DeleteNode("system", *parentID, true) - if err != nil { - t.Fatalf("DeleteNode recursive failed: %v", err) - } - - // Folder should be gone - mustNotExist(t, parentDir) - - // Tree should have no children at root - if len(svc.GetTree().Children) != 0 { - t.Fatalf("expected root to have no children") - } -} - -func TestTreeService_DeletePage_Leaf_Success_RemovesFileAndTreeAndReindexes(t *testing.T) { - svc, tmpDir := newLoadedService(t) - - // Create 3 leaf pages - idA, err := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode A: %v", err) - } - idB, err := svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode B: %v", err) - } - idC, err := svc.CreateNode("system", nil, "C", "c", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode C: %v", err) - } - - // Verify files exist - pathA := filepath.Join(tmpDir, "root", "a.md") - pathB := filepath.Join(tmpDir, "root", "b.md") - pathC := filepath.Join(tmpDir, "root", "c.md") - if _, err := os.Stat(pathB); err != nil { - t.Fatalf("expected %s exists: %v", pathB, err) - } +func TestTreeService_DeleteNode(t *testing.T) { + t.Run("non-recursive errors when node has children", func(t *testing.T) { + svc, _ := newLoadedService(t) - // Delete middle page (B) - if err := svc.DeleteNode("system", *idB, false); err != nil { - t.Fatalf("DeleteNode failed: %v", err) - } + parentID, _ := svc.CreateNode("system", nil, "Parent", "parent", ptrKind(NodeKindPage)) + _, _ = svc.CreateNode("system", parentID, "Child", "child", ptrKind(NodeKindPage)) - // Disk: B gone; A/C still there - if _, err := os.Stat(pathB); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("expected %s to be deleted, got err=%v", pathB, err) - } - if _, err := os.Stat(pathA); err != nil { - t.Fatalf("expected %s exists: %v", pathA, err) - } - if _, err := os.Stat(pathC); err != nil { - t.Fatalf("expected %s exists: %v", pathC, err) - } - - // Tree: only 2 children remain - root := svc.GetTree() - if len(root.Children) != 2 { - t.Fatalf("expected 2 children after delete, got %d", len(root.Children)) - } - - // Ensure deleted ID not present - for _, ch := range root.Children { - if ch.ID == *idB { - t.Fatalf("deleted node still present in tree") + err := svc.DeleteNode("system", *parentID, false) + if !errors.Is(err, ErrPageHasChildren) { + t.Fatalf("expected ErrPageHasChildren, got %v", err) } - } - - // Reindex: positions must be 0..1 (order depends on previous positions; we just assert contiguous) - if root.Children[0].Position != 0 || root.Children[1].Position != 1 { - t.Fatalf("expected positions reindexed to 0..1, got %d,%d", - root.Children[0].Position, root.Children[1].Position) - } - - // Optional: ensure remaining IDs are the ones we expect - _ = idA - _ = idC -} - -func TestTreeService_DeletePage_WithChildren_NonRecursive_ReturnsErrPageHasChildren(t *testing.T) { - svc, _ := newLoadedService(t) - - parentID, err := svc.CreateNode("system", nil, "Parent", "parent", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode parent: %v", err) - } + }) - _, err = svc.CreateNode("system", parentID, "Child", "child", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode child: %v", err) - } + t.Run("recursive delete removes folder and tree node", func(t *testing.T) { + svc, tmpDir := newLoadedService(t) - err = svc.DeleteNode("system", *parentID, false) - if err == nil { - t.Fatalf("expected error deleting page with children without recursive") - } - if !errors.Is(err, ErrPageHasChildren) { - t.Fatalf("expected ErrPageHasChildren, got: %v", err) - } -} + parentID, _ := svc.CreateNode("system", nil, "Parent", "parent", ptrKind(NodeKindPage)) + _, _ = svc.CreateNode("system", parentID, "Child", "child", ptrKind(NodeKindPage)) -func TestTreeService_DeletePage_WithChildren_Recursive_DeletesFolder(t *testing.T) { - svc, tmpDir := newLoadedService(t) + parentDir := filepath.Join(tmpDir, "root", "parent") + mustStat(t, parentDir) - parentID, err := svc.CreateNode("system", nil, "Parent", "parent", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode parent: %v", err) - } - _, err = svc.CreateNode("system", parentID, "Child", "child", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode child: %v", err) - } - - // Parent was auto-converted to section -> folder should exist - parentDir := filepath.Join(tmpDir, "root", "parent") - if _, err := os.Stat(parentDir); err != nil { - t.Fatalf("expected parent dir exists (after auto-convert): %v", err) - } + if err := svc.DeleteNode("system", *parentID, true); err != nil { + t.Fatalf("DeleteNode recursive failed: %v", err) + } - // Recursive delete should remove the folder - if err := svc.DeleteNode("system", *parentID, true); err != nil { - t.Fatalf("DeleteNode recursive failed: %v", err) - } + mustNotExist(t, parentDir) - if _, err := os.Stat(parentDir); !errors.Is(err, os.ErrNotExist) { - t.Fatalf("expected parent folder deleted, got err=%v", err) - } + if len(svc.GetTree().Children) != 0 { + t.Fatalf("expected root to have no children") + } + }) - // Tree should no longer contain parent - if len(svc.GetTree().Children) != 0 { - t.Fatalf("expected root to have no children after delete, got %d", len(svc.GetTree().Children)) - } -} + t.Run("invalid id returns not found", func(t *testing.T) { + svc, _ := newLoadedService(t) -func TestTreeService_DeletePage_InvalidID_ReturnsErrPageNotFound(t *testing.T) { - svc, _ := newLoadedService(t) + err := svc.DeleteNode("system", "does-not-exist", false) + if !errors.Is(err, ErrPageNotFound) { + t.Fatalf("expected ErrPageNotFound, got %v", err) + } + }) - err := svc.DeleteNode("system", "does-not-exist", false) - if err == nil { - t.Fatalf("expected error") - } - if !errors.Is(err, ErrPageNotFound) { - t.Fatalf("expected ErrPageNotFound, got: %v", err) - } -} + t.Run("drift returns DriftError", func(t *testing.T) { + svc, tmpDir := newLoadedService(t) -func TestTreeService_DeletePage_Drift_FileMissing_ReturnsError(t *testing.T) { - svc, tmpDir := newLoadedService(t) + id, err := svc.CreateNode("system", nil, "Ghost", "ghost", ptrKind(NodeKindPage)) + if err != nil { + t.Fatalf("CreateNode: %v", err) + } - // Create a leaf page normally (creates file) - id, err := svc.CreateNode("system", nil, "Ghost", "ghost", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode: %v", err) - } + path := filepath.Join(tmpDir, "root", "ghost.md") + if err := os.Remove(path); err != nil { + t.Fatalf("remove file: %v", err) + } - // Delete the file manually to simulate drift - p := filepath.Join(tmpDir, "root", "ghost.md") - if err := os.Remove(p); err != nil { - t.Fatalf("failed to remove file to simulate drift: %v", err) - } + err = svc.DeleteNode("system", *id, false) + if err == nil { + t.Fatalf("expected drift error") + } - // Now delete node - should error (drift) - err = svc.DeleteNode("system", *id, false) - if err == nil { - t.Fatalf("expected drift error") - } - // If you have a concrete DriftError type, you can assert with errors.As. - var dErr *DriftError - if !errors.As(err, &dErr) { - t.Fatalf("expected DriftError, got: %T (%v)", err, err) - } + var dErr *DriftError + if !errors.As(err, &dErr) { + t.Fatalf("expected DriftError, got %T (%v)", err, err) + } + }) } -// --- C) Move semantics --- +// --- move --- -func TestTreeService_MoveNode_TargetPageAutoConvertsToSection(t *testing.T) { - svc, tmpDir := newLoadedService(t) - - aID, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - bID, _ := svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) +func TestTreeService_MoveNode(t *testing.T) { + t.Run("target page auto converts to section", func(t *testing.T) { + svc, tmpDir := newLoadedService(t) - // Move A under B (B is a page => should auto-convert to section) - if err := svc.MoveNode("system", *aID, *bID); err != nil { - t.Fatalf("MoveNode failed: %v", err) - } + aID, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) + bID, _ := svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) - // B should now be folder with index.md - bDir := filepath.Join(tmpDir, "root", "b") - mustStat(t, bDir) - mustStat(t, filepath.Join(bDir, "index.md")) + if err := svc.MoveNode("system", *aID, *bID); err != nil { + t.Fatalf("MoveNode failed: %v", err) + } - // A should now be inside B folder - aPath := filepath.Join(bDir, "a.md") - mustStat(t, aPath) -} + bDir := filepath.Join(tmpDir, "root", "b") + mustStat(t, bDir) + mustStat(t, filepath.Join(bDir, "index.md")) + mustStat(t, filepath.Join(bDir, "a.md")) + }) -func TestTreeService_MoveNode_PreventsCircularReference(t *testing.T) { - svc, _ := newLoadedService(t) + t.Run("prevents circular reference", func(t *testing.T) { + svc, _ := newLoadedService(t) - aID, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - // create child under A so A becomes section and has child - bID, _ := svc.CreateNode("system", aID, "B", "b", ptrKind(NodeKindPage)) + aID, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) + bID, _ := svc.CreateNode("system", aID, "B", "b", ptrKind(NodeKindPage)) - // Try move A under B (A -> ... -> B). Should error with circular reference. - err := svc.MoveNode("system", *aID, *bID) - if err == nil { - t.Fatalf("expected error moving node under its descendant") - } - if !errors.Is(err, ErrMovePageCircularReference) { - t.Fatalf("expected ErrMovePageCircularReference, got: %v", err) - } -} + err := svc.MoveNode("system", *aID, *bID) + if !errors.Is(err, ErrMovePageCircularReference) { + t.Fatalf("expected ErrMovePageCircularReference, got %v", err) + } + }) -func TestTreeService_MoveNode_PreventsSelfParent(t *testing.T) { - svc, _ := newLoadedService(t) + t.Run("prevents self parent", func(t *testing.T) { + svc, _ := newLoadedService(t) - aID, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) + aID, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - err := svc.MoveNode("system", *aID, *aID) - if err == nil { - t.Fatalf("expected error moving node into itself") - } - if !errors.Is(err, ErrPageCannotBeMovedToItself) { - t.Fatalf("expected ErrPageCannotBeMovedToItself, got: %v", err) - } + err := svc.MoveNode("system", *aID, *aID) + if !errors.Is(err, ErrPageCannotBeMovedToItself) { + t.Fatalf("expected ErrPageCannotBeMovedToItself, got %v", err) + } + }) } -// --- D) SortPages --- +// --- sorting --- -func TestTreeService_SortPages_ValidOrder(t *testing.T) { - svc, _ := newLoadedService(t) +func TestTreeService_SortPages(t *testing.T) { + t.Run("valid order", func(t *testing.T) { + svc, _ := newLoadedService(t) - idA, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - idB, _ := svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) - idC, _ := svc.CreateNode("system", nil, "C", "c", ptrKind(NodeKindPage)) + idA, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) + idB, _ := svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) + idC, _ := svc.CreateNode("system", nil, "C", "c", ptrKind(NodeKindPage)) - err := svc.SortPages("root", []string{*idC, *idA, *idB}) - if err != nil { - t.Fatalf("SortPages failed: %v", err) - } + if err := svc.SortPages("root", []string{*idC, *idA, *idB}); err != nil { + t.Fatalf("SortPages failed: %v", err) + } - root := svc.GetTree() - if root.Children[0].ID != *idC || root.Children[1].ID != *idA || root.Children[2].ID != *idB { - t.Fatalf("unexpected order after sort") - } - if root.Children[0].Position != 0 || root.Children[1].Position != 1 || root.Children[2].Position != 2 { - t.Fatalf("expected positions to be reindexed") - } -} + root := svc.GetTree() + if root.Children[0].ID != *idC || root.Children[1].ID != *idA || root.Children[2].ID != *idB { + t.Fatalf("unexpected order after sort") + } + }) -func TestTreeService_SortPages_InvalidLength(t *testing.T) { - svc, _ := newLoadedService(t) + t.Run("invalid id returns invalid sort order", func(t *testing.T) { + svc, _ := newLoadedService(t) - _, _ = svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - _, _ = svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) + _, _ = svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) + _, _ = svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) - err := svc.SortPages("root", []string{"only-one"}) - if err == nil { - t.Fatalf("expected error for invalid length") - } - if !errors.Is(err, ErrInvalidSortOrder) { - t.Fatalf("expected ErrInvalidSortOrder, got: %v", err) - } -} + err := svc.SortPages("root", []string{"only-one"}) + if !errors.Is(err, ErrInvalidSortOrder) { + t.Fatalf("expected ErrInvalidSortOrder, got %v", err) + } + }) -func TestTreeService_SortPages_DuplicateID(t *testing.T) { - svc, _ := newLoadedService(t) + t.Run("duplicate id errors", func(t *testing.T) { + svc, _ := newLoadedService(t) - idA, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) - idB, _ := svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) + idA, _ := svc.CreateNode("system", nil, "A", "a", ptrKind(NodeKindPage)) + idB, _ := svc.CreateNode("system", nil, "B", "b", ptrKind(NodeKindPage)) - err := svc.SortPages("root", []string{*idA, *idA, *idB}) - if err == nil { - t.Fatalf("expected error for duplicate IDs") - } + err := svc.SortPages("root", []string{*idA, *idA, *idB}) + if err == nil { + t.Fatalf("expected error for duplicate ids") + } + }) } -// --- E) Routing, Lookup, Ensure --- +// --- routing / lookup / ensure --- func TestTreeService_FindPageByRoutePath_ReturnsContent(t *testing.T) { svc, _ := newLoadedService(t) archID, _ := svc.CreateNode("system", nil, "Architecture", "architecture", ptrKind(NodeKindPage)) - // create child -> converts arch to section projectID, _ := svc.CreateNode("system", archID, "Project A", "project-a", ptrKind(NodeKindPage)) _, _ = svc.CreateNode("system", projectID, "Specs", "specs", ptrKind(NodeKindPage)) - // Update specs content specsNode := svc.GetTree().Children[0].Children[0].Children[0] body := "# Specs\nHello" + if err := svc.UpdateNode("system", specsNode.ID, "Specs", "specs", &body); err != nil { t.Fatalf("UpdateNode content failed: %v", err) } @@ -594,7 +364,7 @@ func TestTreeService_FindPageByRoutePath_ReturnsContent(t *testing.T) { t.Fatalf("expected slug specs, got %q", page.Slug) } if !strings.Contains(page.Content, "Hello") { - t.Fatalf("expected content to include Hello, got: %q", page.Content) + t.Fatalf("expected content to include Hello, got %q", page.Content) } } @@ -608,6 +378,7 @@ func TestTreeService_LookupPagePath_Segments(t *testing.T) { if err != nil { t.Fatalf("LookupPagePath failed: %v", err) } + if lookup.Exists { t.Fatalf("expected full path to not exist") } @@ -615,10 +386,10 @@ func TestTreeService_LookupPagePath_Segments(t *testing.T) { t.Fatalf("expected 3 segments, got %d", len(lookup.Segments)) } if !lookup.Segments[0].Exists || lookup.Segments[0].ID == nil { - t.Fatalf("expected home segment to exist with ID") + t.Fatalf("expected home segment to exist") } if !lookup.Segments[1].Exists || lookup.Segments[1].ID == nil { - t.Fatalf("expected about segment to exist with ID") + t.Fatalf("expected about segment to exist") } if lookup.Segments[2].Exists || lookup.Segments[2].ID != nil { t.Fatalf("expected team to not exist") @@ -628,16 +399,15 @@ func TestTreeService_LookupPagePath_Segments(t *testing.T) { func TestTreeService_EnsurePagePath_CreatesIntermediateSectionsAndFinalPage(t *testing.T) { svc, _ := newLoadedService(t) - // Ensure a deep path; intermediate nodes should become sections res, err := svc.EnsurePagePath("system", "home/about/team/members", "Members", ptrKind(NodeKindPage)) if err != nil { t.Fatalf("EnsurePagePath failed: %v", err) } + if res.Page == nil || res.Page.Slug != "members" { t.Fatalf("expected final page 'members'") } - // home/about/team should exist as path now lookup, err := svc.LookupPagePath(svc.GetTree().Children, "home/about/team/members") if err != nil { t.Fatalf("LookupPagePath failed: %v", err) @@ -647,234 +417,11 @@ func TestTreeService_EnsurePagePath_CreatesIntermediateSectionsAndFinalPage(t *t } } -// --- F) Migration V2 (frontmatter backfill) --- -func TestTreeService_LoadTree_MigratesToV2_AddsFrontmatterAndPreservesBody(t *testing.T) { - if CurrentSchemaVersion < 2 { - t.Skip("requires schema v2+") - } - - tmpDir := t.TempDir() - - // start on v1 (or generally: current-1) - if err := saveSchema(tmpDir, CurrentSchemaVersion-1); err != nil { - t.Fatalf("saveSchema failed: %v", err) - } - - svc := NewTreeService(tmpDir) - if err := svc.LoadTree(); err != nil { - t.Fatalf("LoadTree failed: %v", err) - } - - id, err := svc.CreateNode("system", nil, "Page1", "page1", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode failed: %v", err) - } - - // IMPORTANT: persist tree so the next service instance sees the node - if err := svc.SaveTree(); err != nil { - t.Fatalf("SaveTree failed: %v", err) - } - - // overwrite file without FM - pagePath := filepath.Join(tmpDir, "root", "page1.md") - body := "# Page 1 Content\nHello World\n" - if err := os.WriteFile(pagePath, []byte(body), 0o644); err != nil { - t.Fatalf("write old content failed: %v", err) - } - - // force schema old again - if err := saveSchema(tmpDir, CurrentSchemaVersion-1); err != nil { - t.Fatalf("saveSchema failed: %v", err) - } - - loaded := NewTreeService(tmpDir) - if err := loaded.LoadTree(); err != nil { - t.Fatalf("LoadTree (migrating) failed: %v", err) - } - - raw, err := os.ReadFile(pagePath) - if err != nil { - t.Fatalf("read migrated file: %v", err) - } - - fm, migratedBody, has, err := markdown.ParseFrontmatter(string(raw)) - if err != nil { - t.Fatalf("ParseFrontmatter: %v", err) - } - if !has { - t.Fatalf("expected frontmatter after migration, got:\n%s", string(raw)) - } - if fm.LeafWikiID != *id { - t.Fatalf("expected leafwiki_id=%q, got %q", *id, fm.LeafWikiID) - } - if strings.TrimSpace(fm.LeafWikiTitle) == "" { - t.Fatalf("expected leafwiki_title to be set") - } - if migratedBody != body { - t.Fatalf("expected body preserved exactly.\nGot:\n%q\nWant:\n%q", migratedBody, body) - } -} - -// TestTreeService_ReconstructTreeFromFS_UpdatesSchemaVersion verifies that -// ReconstructTreeFromFS writes the current schema version to prevent unnecessary migrations -func TestTreeService_ReconstructTreeFromFS_UpdatesSchemaVersion(t *testing.T) { - tmpDir := t.TempDir() - - // Create a minimal file structure for reconstruction - mustMkdir(t, filepath.Join(tmpDir, "root")) - mustWriteFile(t, filepath.Join(tmpDir, "root", "test.md"), "# Test Page", 0o644) - - // Create service WITHOUT schema.json (simulating an old/missing schema) - svc := NewTreeService(tmpDir) - - // Reconstruct the tree (no prior tree loaded) - if err := svc.ReconstructTreeFromFS(); err != nil { - t.Fatalf("ReconstructTreeFromFS failed: %v", err) - } - - // Verify schema.json was created with current version - schema, err := loadSchema(tmpDir) - if err != nil { - t.Fatalf("loadSchema failed: %v", err) - } - - if schema.Version != CurrentSchemaVersion { - t.Errorf("expected schema version %d after reconstruction, got %d", CurrentSchemaVersion, schema.Version) - } - - // Verify tree.json was also created - mustStat(t, filepath.Join(tmpDir, "tree.json")) -} - -// --- G) ReconstructTreeFromFS --- +// --- reconstruct from filesystem --- -func TestTreeService_ReconstructTreeFromFS_BackfillsMetadata(t *testing.T) { +func TestTreeService_ReconstructTreeFromFS_LoadsProjectionFromDisk(t *testing.T) { svc, tmpDir := newLoadedService(t) - // Create some files on disk manually (simulating external changes) - mustWriteFile(t, filepath.Join(tmpDir, "root", "page1.md"), `--- -leafwiki_id: page-1 -leafwiki_title: Page One ---- -# Page One`, 0o644) - - mustMkdir(t, filepath.Join(tmpDir, "root", "section1")) - mustWriteFile(t, filepath.Join(tmpDir, "root", "section1", "index.md"), `--- -leafwiki_id: sec-1 -leafwiki_title: Section One ---- -# Section One`, 0o644) - - mustWriteFile(t, filepath.Join(tmpDir, "root", "section1", "page2.md"), `--- -leafwiki_id: page-2 -leafwiki_title: Page Two ---- -# Page Two`, 0o644) - - // Reconstruct the tree from filesystem - err := svc.ReconstructTreeFromFS() - if err != nil { - t.Fatalf("ReconstructTreeFromFS failed: %v", err) - } - - // Verify metadata was backfilled for all nodes - tree := svc.GetTree() - - // Check root metadata - if tree.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected root metadata CreatedAt to be backfilled, got zero") - } - if tree.Metadata.UpdatedAt.IsZero() { - t.Fatalf("expected root metadata UpdatedAt to be backfilled, got zero") - } - - // Find and verify page1 - page1 := findChildBySlug(t, tree, "page1") - if page1.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected page1 metadata CreatedAt to be backfilled, got zero") - } - if page1.Metadata.UpdatedAt.IsZero() { - t.Fatalf("expected page1 metadata UpdatedAt to be backfilled, got zero") - } - - // Find and verify section1 - section1 := findChildBySlug(t, tree, "section1") - if section1.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected section1 metadata CreatedAt to be backfilled, got zero") - } - if section1.Metadata.UpdatedAt.IsZero() { - t.Fatalf("expected section1 metadata UpdatedAt to be backfilled, got zero") - } - - // Find and verify page2 (child of section1) - page2 := findChildBySlug(t, section1, "page2") - if page2.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected page2 metadata CreatedAt to be backfilled, got zero") - } - if page2.Metadata.UpdatedAt.IsZero() { - t.Fatalf("expected page2 metadata UpdatedAt to be backfilled, got zero") - } -} - -func TestTreeService_ReconstructTreeFromFS_PersistsTreeJSON(t *testing.T) { - svc, tmpDir := newLoadedService(t) - - // Create some files on disk manually - mustWriteFile(t, filepath.Join(tmpDir, "root", "readme.md"), `--- -leafwiki_id: readme-page -leafwiki_title: README ---- -# README`, 0o644) - - // Verify tree.json doesn't exist or is empty before reconstruction - treeJSONPath := filepath.Join(tmpDir, "tree.json") - - // Reconstruct the tree from filesystem - err := svc.ReconstructTreeFromFS() - if err != nil { - t.Fatalf("ReconstructTreeFromFS failed: %v", err) - } - - // Verify tree.json was persisted - info := mustStat(t, treeJSONPath) - if info.Size() == 0 { - t.Fatalf("expected tree.json to have content after reconstruction, got size 0") - } - - // Verify we can reload the tree from the saved tree.json - newSvc := NewTreeService(tmpDir) - if err := newSvc.LoadTree(); err != nil { - t.Fatalf("LoadTree after reconstruction failed: %v", err) - } - - // Verify the tree structure matches - tree := newSvc.GetTree() - if tree == nil || tree.ID != "root" { - t.Fatalf("expected root node after reload, got: %+v", tree) - } - - // Verify the readme page exists - readme := findChildBySlug(t, tree, "readme") - if readme.ID != "readme-page" { - t.Fatalf("expected readme ID to be 'readme-page', got %q", readme.ID) - } - if readme.Title != "README" { - t.Fatalf("expected readme title to be 'README', got %q", readme.Title) - } - - // Verify metadata was persisted - if readme.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected persisted metadata CreatedAt to not be zero") - } - if readme.Metadata.UpdatedAt.IsZero() { - t.Fatalf("expected persisted metadata UpdatedAt to not be zero") - } -} - -func TestTreeService_ReconstructTreeFromFS_ComplexTree_PreservesStructure(t *testing.T) { - svc, tmpDir := newLoadedService(t) - - // Create a complex tree structure on disk mustWriteFile(t, filepath.Join(tmpDir, "root", "intro.md"), `--- leafwiki_id: intro leafwiki_title: Introduction @@ -894,169 +441,43 @@ leafwiki_title: Getting Started --- # Getting Started`, 0o644) - mustMkdir(t, filepath.Join(tmpDir, "root", "docs", "guides")) - mustWriteFile(t, filepath.Join(tmpDir, "root", "docs", "guides", "index.md"), `--- -leafwiki_id: guides-section -leafwiki_title: Guides ---- -# Guides`, 0o644) - - mustWriteFile(t, filepath.Join(tmpDir, "root", "docs", "guides", "basic.md"), `--- -leafwiki_id: basic-guide -leafwiki_title: Basic Guide ---- -# Basic Guide`, 0o644) - - // Reconstruct - err := svc.ReconstructTreeFromFS() - if err != nil { + if err := svc.ReconstructTreeFromFS(); err != nil { t.Fatalf("ReconstructTreeFromFS failed: %v", err) } tree := svc.GetTree() - // Verify structure intro := findChildBySlug(t, tree, "intro") if intro.Kind != NodeKindPage { - t.Fatalf("expected intro to be a page, got %q", intro.Kind) + t.Fatalf("expected intro to be page, got %q", intro.Kind) } docs := findChildBySlug(t, tree, "docs") if docs.Kind != NodeKindSection { - t.Fatalf("expected docs to be a section, got %q", docs.Kind) + t.Fatalf("expected docs to be section, got %q", docs.Kind) } if docs.ID != "docs-section" { - t.Fatalf("expected docs ID to be 'docs-section', got %q", docs.ID) + t.Fatalf("expected docs ID docs-section, got %q", docs.ID) } gettingStarted := findChildBySlug(t, docs, "getting-started") if gettingStarted.Kind != NodeKindPage { - t.Fatalf("expected getting-started to be a page, got %q", gettingStarted.Kind) - } - - guides := findChildBySlug(t, docs, "guides") - if guides.Kind != NodeKindSection { - t.Fatalf("expected guides to be a section, got %q", guides.Kind) - } - - basic := findChildBySlug(t, guides, "basic") - if basic.Kind != NodeKindPage { - t.Fatalf("expected basic to be a page, got %q", basic.Kind) - } - - // Verify all nodes have metadata - if intro.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected intro to have metadata") - } - if docs.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected docs to have metadata") - } - if guides.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected guides to have metadata") - } - if basic.Metadata.CreatedAt.IsZero() { - t.Fatalf("expected basic to have metadata") - } - - // Verify tree.json was saved and can be reloaded - treeJSONPath := filepath.Join(tmpDir, "tree.json") - mustStat(t, treeJSONPath) - - reloadedSvc := NewTreeService(tmpDir) - if err := reloadedSvc.LoadTree(); err != nil { - t.Fatalf("LoadTree after reconstruction failed: %v", err) - } - - reloadedTree := reloadedSvc.GetTree() - if len(reloadedTree.Children) != len(tree.Children) { - t.Fatalf("expected reloaded tree to have same number of children") + t.Fatalf("expected getting-started to be page, got %q", gettingStarted.Kind) } } -func TestTreeService_ReconstructTreeFromFS_EmptyDirectory_CreatesRootAndPersists(t *testing.T) { - svc, tmpDir := newLoadedService(t) +func TestTreeService_ReconstructTreeFromFS_EmptyDirectoryReturnsRoot(t *testing.T) { + svc, _ := newLoadedService(t) - // Reconstruct from empty directory (should create just root) - err := svc.ReconstructTreeFromFS() - if err != nil { + if err := svc.ReconstructTreeFromFS(); err != nil { t.Fatalf("ReconstructTreeFromFS failed: %v", err) } tree := svc.GetTree() if tree == nil || tree.ID != "root" { - t.Fatalf("expected root node, got: %+v", tree) - } - - // Note: Root metadata may not be backfilled from filesystem when directory is empty - // because there's no corresponding file/directory to stat. This is expected behavior. - // The important thing is that the tree is reconstructed and persisted. - - // Verify tree.json was saved - treeJSONPath := filepath.Join(tmpDir, "tree.json") - mustStat(t, treeJSONPath) - - // Verify we can reload - reloadedSvc := NewTreeService(tmpDir) - if err := reloadedSvc.LoadTree(); err != nil { - t.Fatalf("LoadTree after reconstruction failed: %v", err) + t.Fatalf("expected root node, got %+v", tree) } - - reloadedTree := reloadedSvc.GetTree() - if reloadedTree == nil || reloadedTree.ID != "root" { - t.Fatalf("expected root node after reload") + if len(tree.Children) != 0 { + t.Fatalf("expected empty root") } } - -func TestTreeService_ReconstructTreeFromFS_RevertsOnMetadataBackfillError(t *testing.T) { - // This test is harder to trigger without mocking, but we can at least verify - // that if the tree state is preserved if we can cause a failure scenario. - // For now, we'll test that a successful reconstruction doesn't lose the old tree. - svc, tmpDir := newLoadedService(t) - - // Create initial tree state - initialID, err := svc.CreateNode("system", nil, "Initial", "initial", ptrKind(NodeKindPage)) - if err != nil { - t.Fatalf("CreateNode failed: %v", err) - } - - // Get initial tree - initialTree := svc.GetTree() - if len(initialTree.Children) != 1 { - t.Fatalf("expected 1 child in initial tree") - } - - // Create a new file on disk - mustWriteFile(t, filepath.Join(tmpDir, "root", "new-page.md"), `--- -leafwiki_id: new-page -leafwiki_title: New Page ---- -# New Page`, 0o644) - - // Reconstruct should succeed - err = svc.ReconstructTreeFromFS() - if err != nil { - t.Fatalf("ReconstructTreeFromFS failed: %v", err) - } - - // Verify new tree has both nodes - newTree := svc.GetTree() - if len(newTree.Children) != 2 { - t.Fatalf("expected 2 children after reconstruction, got %d", len(newTree.Children)) - } - - // Verify initial node still exists - var foundInitial bool - for _, child := range newTree.Children { - if child.ID == *initialID { - foundInitial = true - break - } - } - if !foundInitial { - t.Fatalf("expected initial node to still exist after reconstruction") - } -} - -// --- small util --- - -func ptrKind(k NodeKind) *NodeKind { return &k } diff --git a/internal/http/router_test.go b/internal/http/router_test.go index fad63daa..8c39d02c 100644 --- a/internal/http/router_test.go +++ b/internal/http/router_test.go @@ -36,7 +36,7 @@ func createWikiTestInstance(t *testing.T) *wiki.Wiki { return w } -func createRouterTestInstance(w *wiki.Wiki, t *testing.T) *gin.Engine { +func createRouterTestInstance(w *wiki.Wiki) *gin.Engine { return NewRouter(w, RouterOptions{ PublicAccess: false, InjectCodeInHeader: "", @@ -161,7 +161,7 @@ func authenticatedRequestAs(t *testing.T, router http.Handler, username, passwor func TestCreatePageEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) title := "Getting Started" expectedSlug := "getting-started" @@ -195,7 +195,7 @@ func TestCreatePageEndpoint(t *testing.T) { func TestCreatePageEndpoint_MissingTitle(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"title": ""}` rec := authenticatedRequest(t, router, http.MethodPost, "/api/pages", strings.NewReader(body)) @@ -208,7 +208,7 @@ func TestCreatePageEndpoint_MissingTitle(t *testing.T) { func TestCreatePageEndpoint_InvalidJSON(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `this is not valid json` rec := authenticatedRequest(t, router, http.MethodPost, "/api/pages", strings.NewReader(body)) @@ -221,7 +221,7 @@ func TestCreatePageEndpoint_InvalidJSON(t *testing.T) { func TestCreatePageEndpoint_PageAlreadyExists(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"title": "Page Exists", "slug": "page-exists"}` rec1 := authenticatedRequest(t, router, http.MethodPost, "/api/pages", strings.NewReader(body)) @@ -240,7 +240,7 @@ func TestCreatePageEndpoint_PageAlreadyExists(t *testing.T) { func TestGetTreeEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/tree", nil) @@ -274,7 +274,7 @@ func TestGetTreeEndpoint(t *testing.T) { func TestSuggestSlugEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/pages/slug-suggestion?title=NewPage", nil) @@ -299,7 +299,7 @@ func TestSuggestSlugEndpoint(t *testing.T) { func TestSuggestSlugEndpoint_MissingTitle(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/pages/slug-suggestion", nil) @@ -311,7 +311,7 @@ func TestSuggestSlugEndpoint_MissingTitle(t *testing.T) { func TestDeletePageEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) _, err := w.CreateNode("system", nil, "Delete Me", "delete-me", pageNodeKind()) if err != nil { @@ -332,7 +332,7 @@ func TestDeletePageEndpoint(t *testing.T) { func TestDeletePageEndpoint_NotFound(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodDelete, "/api/pages/not-found-id", nil) @@ -344,7 +344,7 @@ func TestDeletePageEndpoint_NotFound(t *testing.T) { func TestDeletePageEndpoint_HasChildren(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) parent, err := w.CreateNode("system", nil, "Parent", "parent", pageNodeKind()) if err != nil { @@ -365,7 +365,7 @@ func TestDeletePageEndpoint_HasChildren(t *testing.T) { func TestDeletePageEndpoint_Recursive(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) parent, err := w.CreateNode("system", nil, "Parent", "parent", pageNodeKind()) if err != nil { @@ -389,7 +389,7 @@ func TestDeletePageEndpoint_Recursive(t *testing.T) { func TestUpdatePageEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) _, err := w.CreateNode("system", nil, "Original Title", "original-title", pageNodeKind()) if err != nil { @@ -429,7 +429,7 @@ func TestUpdatePageEndpoint(t *testing.T) { func TestUpdatePage_NotFound(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"title": "Updated", "slug": "updated", "content": "New content"}` rec := authenticatedRequest(t, router, http.MethodPut, "/api/pages/not-found-id", strings.NewReader(string(body))) @@ -441,7 +441,7 @@ func TestUpdatePage_NotFound(t *testing.T) { func TestUpdatePage_SlugRemainsIfUnchanged(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create a page created, err := w.CreateNode("system", nil, "Immutable Slug", "immutable-slug", pageNodeKind()) @@ -476,7 +476,7 @@ func TestUpdatePage_SlugRemainsIfUnchanged(t *testing.T) { func TestUpdatePage_PageAlreadyExists(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) _, err := w.CreateNode("system", nil, "Original Title", "original-title", pageNodeKind()) if err != nil { @@ -506,7 +506,7 @@ func TestUpdatePage_PageAlreadyExists(t *testing.T) { func TestUpdatePage_InvalidJSON(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `this is not valid json` rec := authenticatedRequest(t, router, http.MethodPut, "/api/pages/invalid-id", strings.NewReader(string(body))) @@ -519,7 +519,7 @@ func TestUpdatePage_InvalidJSON(t *testing.T) { func TestUpdatePage_MissingTitle(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"slug": "updated", "content": "New content"}` rec := authenticatedRequest(t, router, http.MethodPut, "/api/pages/missing-title", strings.NewReader(string(body))) @@ -531,7 +531,7 @@ func TestUpdatePage_MissingTitle(t *testing.T) { func TestUpdatePage_MissingSlug(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"title": "Updated", "content": "New content"}` rec := authenticatedRequest(t, router, http.MethodPut, "/api/pages/missing-slug", strings.NewReader(string(body))) @@ -544,7 +544,7 @@ func TestUpdatePage_MissingSlug(t *testing.T) { func TestGetPageEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create a page _, err := w.CreateNode("system", nil, "Welcome", "welcome", pageNodeKind()) @@ -582,7 +582,7 @@ func TestGetPageEndpoint(t *testing.T) { func TestGetPageEndpoint_NotFound(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/pages/not-found-id", nil) @@ -594,7 +594,7 @@ func TestGetPageEndpoint_NotFound(t *testing.T) { func TestGetPageEndpoint_MissingID(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/pages/", nil) @@ -606,7 +606,7 @@ func TestGetPageEndpoint_MissingID(t *testing.T) { func TestGetPageByPathEndpoint_MissingPath(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/pages/by-path", nil) @@ -618,7 +618,7 @@ func TestGetPageByPathEndpoint_MissingPath(t *testing.T) { func TestGetPageByPathEndpoint_NotFound(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/pages/by-path?path=does-not-exist", nil) @@ -630,7 +630,7 @@ func TestGetPageByPathEndpoint_NotFound(t *testing.T) { func TestGetPageByPathEndpoint_PageReturnsNoChildren(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create a standalone page (no children – adding children auto-converts it to a section) _, err := w.CreateNode("system", nil, "My Page", "my-page", pageNodeKind()) @@ -661,7 +661,7 @@ func TestGetPageByPathEndpoint_PageReturnsNoChildren(t *testing.T) { func TestGetPageByPathEndpoint_SectionReturnsDirectChildrenOnly(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) sectionKind := tree.NodeKindSection @@ -709,7 +709,7 @@ func TestGetPageByPathEndpoint_SectionReturnsDirectChildrenOnly(t *testing.T) { func TestMovePageEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create two pages a and b _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) @@ -740,7 +740,7 @@ func TestMovePageEndpoint(t *testing.T) { func TestMovePageEndpoint_NotFound(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodPut, "/api/pages/not-found-id/move", strings.NewReader(`{"parentId":"root"}`)) @@ -752,7 +752,7 @@ func TestMovePageEndpoint_NotFound(t *testing.T) { func TestMovePageEndpoint_InvalidJSON(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodPut, "/api/pages/invalid-id/move", strings.NewReader(`this is not valid json`)) @@ -764,7 +764,7 @@ func TestMovePageEndpoint_InvalidJSON(t *testing.T) { func TestMovePageEndpoint_MissingParentID(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodPut, "/api/pages/missing-parent/move", strings.NewReader(`{"parentId":""}`)) @@ -776,7 +776,7 @@ func TestMovePageEndpoint_MissingParentID(t *testing.T) { func TestMovePageEndpoint_ParentNotFound(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { @@ -797,7 +797,7 @@ func TestMovePageEndpoint_ParentNotFound(t *testing.T) { func TestMovePageEndpoint_CircularReference(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { @@ -822,7 +822,7 @@ func TestMovePageEndpoint_CircularReference(t *testing.T) { func TestMovePage_FailsIfTargetAlreadyHasPageWithSameSlug(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { @@ -852,7 +852,7 @@ func TestMovePage_FailsIfTargetAlreadyHasPageWithSameSlug(t *testing.T) { func TestMovePage_InTheSamePlace(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) _, err := w.CreateNode("system", nil, "Section A", "section-a", pageNodeKind()) if err != nil { @@ -870,7 +870,7 @@ func TestMovePage_InTheSamePlace(t *testing.T) { func TestSortPagesEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create pages page1, err := w.CreateNode("system", nil, "Page 1", "page-1", pageNodeKind()) @@ -938,7 +938,7 @@ func TestSortPagesEndpoint(t *testing.T) { func TestAuthLoginEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"identifier": "admin", "password": "admin"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(body)) @@ -964,7 +964,7 @@ func TestAuthLoginEndpoint(t *testing.T) { func TestAuthLogin_InvalidCredentials(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"identifier": "admin", "password": "wrong"}` req := httptest.NewRequest(http.MethodPost, "/api/auth/login", strings.NewReader(body)) @@ -981,7 +981,7 @@ func TestAuthLogin_InvalidCredentials(t *testing.T) { func TestAuthRefreshToken(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // 1) Login loginBody := `{"identifier": "admin", "password": "admin"}` @@ -1041,7 +1041,7 @@ func TestAuthRefreshToken(t *testing.T) { func TestCreateUserEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"username": "john", "email": "john@example.com", "password": "secret123", "role": "editor"}` rec := authenticatedRequest(t, router, http.MethodPost, "/api/users", strings.NewReader(body)) @@ -1054,7 +1054,7 @@ func TestCreateUserEndpoint(t *testing.T) { func TestCreateUser_DuplicateEmailOrUsername(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create initial user payload := `{"username": "john", "email": "john@example.com", "password": "secret", "role": "editor"}` @@ -1078,7 +1078,7 @@ func TestCreateUser_DuplicateEmailOrUsername(t *testing.T) { func TestCreateUser_InvalidRole(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"username": "sam", "email": "sam@example.com", "password": "secret1234", "role": "undefined"}` rec := authenticatedRequest(t, router, http.MethodPost, "/api/users", strings.NewReader(body)) @@ -1091,7 +1091,7 @@ func TestCreateUser_InvalidRole(t *testing.T) { func TestCreateUser_WithViewerRole(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) body := `{"username": "vieweruser", "email": "viewer@example.com", "password": "secret1234", "role": "viewer"}` rec := authenticatedRequest(t, router, http.MethodPost, "/api/users", strings.NewReader(body)) @@ -1104,7 +1104,7 @@ func TestCreateUser_WithViewerRole(t *testing.T) { func TestUpdateUser_RoleToViewer(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create user create := `{"username": "jane", "email": "jane@example.com", "password": "secretpassword", "role": "editor"}` @@ -1129,7 +1129,7 @@ func TestUpdateUser_RoleToViewer(t *testing.T) { func TestViewer_CannotCreatePage(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create a viewer user createUserBody := `{"username": "vieweruser", "email": "viewer@example.com", "password": "viewerpass", "role": "viewer"}` @@ -1147,7 +1147,7 @@ func TestViewer_CannotCreatePage(t *testing.T) { func TestViewer_CannotUploadAsset(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create a viewer user createUserBody := `{"username": "vieweruser2", "email": "viewer2@example.com", "password": "viewerpass2", "role": "viewer"}` @@ -1171,7 +1171,7 @@ func TestViewer_CannotUploadAsset(t *testing.T) { func TestViewer_CannotUpdatePage(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create a viewer user createUserBody := `{"username": "vieweruser3", "email": "viewer3@example.com", "password": "viewerpass3", "role": "viewer"}` @@ -1196,7 +1196,7 @@ func TestViewer_CannotUpdatePage(t *testing.T) { func TestViewer_CannotDeletePage(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create a viewer user createUserBody := `{"username": "vieweruser4", "email": "viewer4@example.com", "password": "viewerpass4", "role": "viewer"}` @@ -1220,7 +1220,7 @@ func TestViewer_CannotDeletePage(t *testing.T) { func TestGetUsersEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) rec := authenticatedRequest(t, router, http.MethodGet, "/api/users", nil) if rec.Code != http.StatusOK { @@ -1240,7 +1240,7 @@ func TestGetUsersEndpoint(t *testing.T) { func TestUpdateUserEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create user create := `{"username": "jane", "email": "jane@example.com", "password": "secretpassword", "role": "editor"}` @@ -1266,7 +1266,7 @@ func TestUpdateUserEndpoint(t *testing.T) { func TestDeleteUserEndpoint(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Create user create := `{"username": "todelete", "email": "delete@example.com", "password": "secrepassword", "role": "editor"}` @@ -1284,7 +1284,7 @@ func TestDeleteUserEndpoint(t *testing.T) { func TestDeleteAdminUser_ShouldFail(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Get default admin rec := authenticatedRequest(t, router, http.MethodGet, "/api/users", nil) @@ -1312,7 +1312,7 @@ func TestDeleteAdminUser_ShouldFail(t *testing.T) { func TestRequireAdminMiddleware(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Default Admin create user should succeed body := `{"username": "mod", "email": "mod@example.com", "password": "secretpassword", "role": "editor"}` @@ -1371,7 +1371,7 @@ func TestRequireAdminMiddleware_BlockedWhenAuthDisabled(t *testing.T) { func TestRequireAuthMiddleware_Unauthorized(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Request ohne Token req := httptest.NewRequest(http.MethodPost, "/api/pages", strings.NewReader(`{"title": "Oops", "slug": "oops"}`)) @@ -1388,7 +1388,7 @@ func TestRequireAuthMiddleware_Unauthorized(t *testing.T) { func TestRequireAuthMiddleware_InvalidToken(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) req := httptest.NewRequest(http.MethodPost, "/api/pages", strings.NewReader(`{"title": "Bad", "slug": "bad"}`)) req.Header.Set("Authorization", "Bearer invalidtoken") @@ -1405,7 +1405,7 @@ func TestRequireAuthMiddleware_InvalidToken(t *testing.T) { func TestAssetEndpoints(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Step 0: Login als Admin und Cookies holen loginBody := `{"identifier": "admin", "password": "admin"}` @@ -1546,7 +1546,7 @@ func TestIndexingStatusEndpoint(t *testing.T) { // Lets call /api/search/status w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - router := createRouterTestInstance(w, t) + router := createRouterTestInstance(w) // Default Admin holen rec := authenticatedRequest(t, router, http.MethodGet, "/api/search/status", nil) diff --git a/internal/wiki/wiki_test.go b/internal/wiki/wiki_test.go index 874cb671..20712a7f 100644 --- a/internal/wiki/wiki_test.go +++ b/internal/wiki/wiki_test.go @@ -10,7 +10,9 @@ import ( ) func createWikiTestInstance(t *testing.T) *Wiki { - wikiInstance, err := NewWiki(&WikiOptions{ + t.Helper() + + w, err := NewWiki(&WikiOptions{ StorageDir: t.TempDir(), AdminPassword: "admin", JWTSecret: "secretkey", @@ -20,413 +22,481 @@ func createWikiTestInstance(t *testing.T) *Wiki { if err != nil { t.Fatalf("Failed to create wiki instance: %v", err) } - return wikiInstance + return w } -func pageNodeKind() *tree.NodeKind { - kind := tree.NodeKindPage - return &kind +func pageKind() *tree.NodeKind { + k := tree.NodeKindPage + return &k } -func TestWiki_CreatePage_Root(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) +func mustCreateNode(t *testing.T, w *Wiki, parentID *string, title, slug string, kind *tree.NodeKind) *tree.Page { + t.Helper() - page, err := w.CreatePage("system", nil, "Home", "home", pageNodeKind()) + p, err := w.CreateNode("system", parentID, title, slug, kind) if err != nil { - t.Fatalf("CreatePage failed: %v", err) - } - - if page.Title != "Home" { - t.Errorf("Expected title 'Home', got %q", page.Title) + t.Fatalf("CreateNode failed: %v", err) } + return p } -func TestWiki_CreatePage_WithParent(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - kind := tree.NodeKindPage - rootPage, _ := w.CreatePage("system", nil, "Docs", "docs", &kind) +func TestWiki_CreateNode(t *testing.T) { + t.Run("creates page at root", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - page, err := w.CreatePage("system", &rootPage.ID, "API-Doc", "api-doc", &kind) - if err != nil { - t.Fatalf("CreatePage with parent failed: %v", err) - } + page := mustCreateNode(t, w, nil, "Home", "home", pageKind()) - if page.Parent.ID != rootPage.ID { - t.Errorf("Expected parent ID %q, got %q", rootPage.ID, page.Parent.ID) - } -} + if page.Title != "Home" { + t.Fatalf("expected title %q, got %q", "Home", page.Title) + } + if page.Slug != "home" { + t.Fatalf("expected slug %q, got %q", "home", page.Slug) + } + }) -func TestWiki_CreatePage_EmptyTitle(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, err := w.CreatePage("system", nil, "", "empty", pageNodeKind()) - if err == nil { - t.Error("Expected error for empty title, got none") - } -} + t.Run("creates page with parent", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) -func TestWiki_CreatePage_ReservedSlug(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, err := w.CreatePage("system", nil, "Reserved", "e", pageNodeKind()) - if err == nil { - t.Error("Expected error for reserved slug, got none") - } + parent := mustCreateNode(t, w, nil, "Docs", "docs", pageKind()) + page := mustCreateNode(t, w, &parent.ID, "API Doc", "api-doc", pageKind()) - // Check if the error message is correct - if ve, ok := err.(*verrors.ValidationErrors); ok { - if len(ve.Errors) != 1 || ve.Errors[0].Field != "slug" { - t.Errorf("Expected validation error for slug, got %v", ve) + if page.Parent == nil || page.Parent.ID != parent.ID { + t.Fatalf("expected parent ID %q, got %#v", parent.ID, page.Parent) } - } else { - t.Errorf("Expected ValidationErrors, got %T", err) - } -} + }) -func TestWiki_CreatePage_PageExists(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, _ = w.CreatePage("system", nil, "Duplicate", "duplicate", pageNodeKind()) + t.Run("rejects duplicate slug under same parent", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, err := w.CreatePage("system", nil, "Duplicate", "duplicate", pageNodeKind()) - if err == nil { - t.Error("Expected error for duplicate page, got none") - } -} + _ = mustCreateNode(t, w, nil, "Duplicate", "duplicate", pageKind()) -func TestWiki_CreatePage_InvalidParent(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - invalidID := "not-real" - _, err := w.CreatePage("system", &invalidID, "Broken", "broken", pageNodeKind()) - if err == nil { - t.Error("Expected error with invalid parent ID, got none") - } + _, err := w.CreateNode("system", nil, "Duplicate", "duplicate", pageKind()) + if err == nil { + t.Fatalf("expected error for duplicate page") + } + }) } -func TestWiki_GetPage_ValidID(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - page, _ := w.CreatePage("system", nil, "ReadMe", "readme", pageNodeKind()) +func TestWiki_CreateNode_Validation(t *testing.T) { + tests := []struct { + name string + title string + slug string + parentID *string + wantErr bool + check func(t *testing.T, err error) + }{ + { + name: "rejects empty title", + title: "", + slug: "empty", + wantErr: true, + }, + { + name: "rejects reserved slug", + title: "Reserved", + slug: "e", + wantErr: true, + check: func(t *testing.T, err error) { + t.Helper() + ve, ok := err.(*verrors.ValidationErrors) + if !ok { + t.Fatalf("expected ValidationErrors, got %T", err) + } + if len(ve.Errors) != 1 || ve.Errors[0].Field != "slug" { + t.Fatalf("expected validation error for slug, got %#v", ve) + } + }, + }, + { + name: "rejects invalid parent", + title: "Broken", + slug: "broken", + parentID: func() *string { + s := "not-real" + return &s + }(), + wantErr: true, + }, + { + name: "rejects nil kind", + title: "Broken", + slug: "broken", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + + var kind *tree.NodeKind + if tc.name != "rejects nil kind" { + kind = pageKind() + } + + _, err := w.CreateNode("system", tc.parentID, tc.title, tc.slug, kind) + if tc.wantErr && err == nil { + t.Fatalf("expected error, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + if tc.check != nil { + tc.check(t, err) + } + }) + } +} + +func TestWiki_GetPage(t *testing.T) { + t.Run("valid id", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + + page := mustCreateNode(t, w, nil, "ReadMe", "readme", pageKind()) + + found, err := w.GetPage(page.ID) + if err != nil { + t.Fatalf("GetPage failed: %v", err) + } + if found.ID != page.ID { + t.Fatalf("expected ID %q, got %q", page.ID, found.ID) + } + }) - found, err := w.GetPage(page.ID) - if err != nil { - t.Fatalf("GetPage failed: %v", err) - } + t.Run("invalid id", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - if found.ID != page.ID { - t.Errorf("Expected ID %q, got %q", page.ID, found.ID) - } + _, err := w.GetPage("unknown") + if err == nil { + t.Fatalf("expected error for unknown ID") + } + }) } -func TestWiki_GetPage_InvalidID(t *testing.T) { +func TestWiki_UpdatePage(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, err := w.GetPage("unknown") - if err == nil { - t.Error("Expected error for unknown ID, got none") - } -} -func TestWiki_MovePage_Valid(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - parent, _ := w.CreatePage("system", nil, "Projects", "projects", pageNodeKind()) - child, _ := w.CreatePage("system", nil, "Old", "old", pageNodeKind()) + page := mustCreateNode(t, w, nil, "Draft", "draft", pageKind()) - err := w.MovePage("system", child.ID, parent.ID) + content := "# Updated" + updatedPage, err := w.UpdatePage("system", page.ID, "Final", "final", &content, pageKind()) if err != nil { - t.Fatalf("MovePage failed: %v", err) + t.Fatalf("UpdatePage failed: %v", err) } -} -func TestWiki_DeletePage_Simple(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - page, _ := w.CreatePage("system", nil, "Trash", "trash", pageNodeKind()) - err := w.DeletePage("system", page.ID, false) + if updatedPage.Title != "Final" { + t.Fatalf("expected title %q, got %q", "Final", updatedPage.Title) + } + if updatedPage.Slug != "final" { + t.Fatalf("expected slug %q, got %q", "final", updatedPage.Slug) + } + + updated, err := w.GetPage(page.ID) if err != nil { - t.Fatalf("DeletePage failed: %v", err) + t.Fatalf("GetPage failed: %v", err) + } + if updated.Title != "Final" { + t.Fatalf("expected persisted title %q, got %q", "Final", updated.Title) } } -func TestWiki_DeletePage_WithChildren(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - parent, _ := w.CreatePage("system", nil, "Parent", "parent", pageNodeKind()) - _, _ = w.CreatePage("system", &parent.ID, "Child", "child", pageNodeKind()) +func TestWiki_DeletePage(t *testing.T) { + t.Run("simple delete", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - err := w.DeletePage("system", parent.ID, false) - if err == nil { - t.Error("Expected error when deleting parent with children") - } -} + page := mustCreateNode(t, w, nil, "Trash", "trash", pageKind()) -func TestWiki_DeletePage_Recursive(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - parent, _ := w.CreatePage("system", nil, "Parent", "parent", pageNodeKind()) - _, _ = w.CreatePage("system", &parent.ID, "Child", "child", pageNodeKind()) + if err := w.DeletePage("system", page.ID, false); err != nil { + t.Fatalf("DeletePage failed: %v", err) + } - err := w.DeletePage("system", parent.ID, true) - if err != nil { - t.Fatalf("DeletePage recursive failed: %v", err) - } -} + if _, err := w.GetPage(page.ID); err == nil { + t.Fatalf("expected page to be deleted") + } + }) -func TestWiki_DeletePage_RootWithIDRoot(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + t.Run("delete with children non-recursive errors", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - err := w.DeletePage("system", "root", false) - if err == nil { - t.Error("Expected error when attempting to delete root page with ID 'root', got none") - } + parent := mustCreateNode(t, w, nil, "Parent", "parent", pageKind()) + _ = mustCreateNode(t, w, &parent.ID, "Child", "child", pageKind()) - expectedMsg := "cannot delete root page" - if err.Error() != expectedMsg { - t.Errorf("Expected error message %q, got %q", expectedMsg, err.Error()) - } -} + err := w.DeletePage("system", parent.ID, false) + if err == nil { + t.Fatalf("expected error when deleting parent with children") + } + }) -func TestWiki_DeletePage_RootWithEmptyString(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + t.Run("delete with children recursive succeeds", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - err := w.DeletePage("system", "", false) - if err == nil { - t.Error("Expected error when attempting to delete root page with empty string ID, got none") - } + parent := mustCreateNode(t, w, nil, "Parent", "parent", pageKind()) + _ = mustCreateNode(t, w, &parent.ID, "Child", "child", pageKind()) - expectedMsg := "cannot delete root page" - if err.Error() != expectedMsg { - t.Errorf("Expected error message %q, got %q", expectedMsg, err.Error()) - } -} + if err := w.DeletePage("system", parent.ID, true); err != nil { + t.Fatalf("DeletePage recursive failed: %v", err) + } + }) -func TestWiki_UpdatePage(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + t.Run("root id is rejected", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - page, _ := w.CreatePage("system", nil, "Draft", "draft", pageNodeKind()) - var updatedstr = "# Updated" - page, err := w.UpdatePage("system", page.ID, "Final", "final", &updatedstr, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage failed: %v", err) - } + err := w.DeletePage("system", "root", false) + if err == nil { + t.Fatalf("expected error deleting root") + } + if err.Error() != "cannot delete root page" { + t.Fatalf("unexpected error: %q", err.Error()) + } + }) - updated, _ := w.GetPage(page.ID) - if updated.Title != "Final" { - t.Errorf("Expected title 'Final', got %q", updated.Title) - } -} + t.Run("empty id is rejected", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) -func TestWiki_SuggestSlug_Unique(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - slug, err := w.SuggestSlug("root", "", "My Page") - if err != nil { - t.Fatalf("SuggestSlug failed: %v", err) - } - if slug != "my-page" { - t.Errorf("Expected 'my-page', got %q", slug) - } + err := w.DeletePage("system", "", false) + if err == nil { + t.Fatalf("expected error deleting empty id") + } + if err.Error() != "cannot delete root page" { + t.Fatalf("unexpected error: %q", err.Error()) + } + }) } -func TestWiki_SuggestSlug_Conflict(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - root := w.GetTree() - _, err := w.CreatePage("system", nil, "My Page", "my-page", pageNodeKind()) +func TestWiki_MovePage(t *testing.T) { + t.Run("valid move", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - if err != nil { - t.Fatalf("CreatePage failed: %v", err) - } + parent := mustCreateNode(t, w, nil, "Projects", "projects", pageKind()) + child := mustCreateNode(t, w, nil, "Old", "old", pageKind()) - slug, err := w.SuggestSlug(root.ID, "", "My Page") - if err != nil { - t.Fatalf("SuggestSlug failed: %v", err) - } - if slug != "my-page-1" { - t.Errorf("Expected 'my-page-1', got %q", slug) - } + if err := w.MovePage("system", child.ID, parent.ID); err != nil { + t.Fatalf("MovePage failed: %v", err) + } + + moved, err := w.GetPage(child.ID) + if err != nil { + t.Fatalf("GetPage failed: %v", err) + } + if moved.Parent == nil || moved.Parent.ID != parent.ID { + t.Fatalf("expected parent ID %q, got %#v", parent.ID, moved.Parent) + } + }) } -func TestWiki_SuggestSlug_DeepHierarchy(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) +func TestWiki_SuggestSlug(t *testing.T) { + t.Run("unique at root", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - // create a deep hierarchy of pages (Architecture -> Backend) - _, err := w.CreatePage("system", nil, "Architecture", "architecture", pageNodeKind()) - if err != nil { - t.Fatalf("Failed to create 'Architecture': %v", err) - } - root := w.GetTree() - arch := root.Children[0] + slug, err := w.SuggestSlug("root", "", "My Page") + if err != nil { + t.Fatalf("SuggestSlug failed: %v", err) + } + if slug != "my-page" { + t.Fatalf("expected %q, got %q", "my-page", slug) + } + }) - _, err = w.CreatePage("system", &arch.ID, "Backend", "backend", pageNodeKind()) - if err != nil { - t.Fatalf("Failed to create 'Backend': %v", err) - } - backend := arch.Children[0] + t.Run("conflict at root", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - // Now suggest a slug there - slug, err := w.SuggestSlug(backend.ID, "", "Data Layer") - if err != nil { - t.Fatalf("SuggestSlug failed: %v", err) - } + _ = mustCreateNode(t, w, nil, "My Page", "my-page", pageKind()) - if slug != "data-layer" { - t.Errorf("Expected 'data-layer', got %q", slug) - } + slug, err := w.SuggestSlug("root", "", "My Page") + if err != nil { + t.Fatalf("SuggestSlug failed: %v", err) + } + if slug != "my-page-1" { + t.Fatalf("expected %q, got %q", "my-page-1", slug) + } + }) - // Create a second one with the same name → it must be numbered - _, err = w.CreatePage("system", &backend.ID, "Data Layer", "data-layer", pageNodeKind()) - if err != nil { - t.Fatalf("Failed to create 'Data Layer': %v", err) - } + t.Run("deep hierarchy", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - slug2, err := w.SuggestSlug(backend.ID, "", "Data Layer") - if err != nil { - t.Fatalf("SuggestSlug 2 failed: %v", err) - } + arch := mustCreateNode(t, w, nil, "Architecture", "architecture", pageKind()) + backend := mustCreateNode(t, w, &arch.ID, "Backend", "backend", pageKind()) - if slug2 != "data-layer-1" { - t.Errorf("Expected 'data-layer-1', got %q", slug2) - } -} + slug, err := w.SuggestSlug(backend.ID, "", "Data Layer") + if err != nil { + t.Fatalf("SuggestSlug failed: %v", err) + } + if slug != "data-layer" { + t.Fatalf("expected %q, got %q", "data-layer", slug) + } -func TestWiki_FindByPath_Valid(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, _ = w.CreatePage("system", nil, "Company", "company", pageNodeKind()) + _ = mustCreateNode(t, w, &backend.ID, "Data Layer", "data-layer", pageKind()) - found, err := w.FindByPath("company") - if err != nil { - t.Fatalf("FindByPath failed: %v", err) - } - if found.Slug != "company" { - t.Errorf("Expected slug 'company', got %q", found.Slug) - } + slug2, err := w.SuggestSlug(backend.ID, "", "Data Layer") + if err != nil { + t.Fatalf("SuggestSlug failed: %v", err) + } + if slug2 != "data-layer-1" { + t.Fatalf("expected %q, got %q", "data-layer-1", slug2) + } + }) } -func TestWiki_FindByPath_Invalid(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, err := w.FindByPath("does/not/exist") - if err == nil { - t.Error("Expected error for invalid path, got none") - } +func TestWiki_FindByPath(t *testing.T) { + t.Run("valid", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + + _ = mustCreateNode(t, w, nil, "Company", "company", pageKind()) + + found, err := w.FindByPath("company") + if err != nil { + t.Fatalf("FindByPath failed: %v", err) + } + if found.Slug != "company" { + t.Fatalf("expected slug %q, got %q", "company", found.Slug) + } + }) + + t.Run("invalid", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + + _, err := w.FindByPath("does/not/exist") + if err == nil { + t.Fatalf("expected error for invalid path") + } + }) } func TestWiki_SortPages(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - parent, _ := w.CreatePage("system", nil, "Parent", "parent", pageNodeKind()) - child1, _ := w.CreatePage("system", &parent.ID, "Child1", "child1", pageNodeKind()) - child2, _ := w.CreatePage("system", &parent.ID, "Child2", "child2", pageNodeKind()) - err := w.SortPages(parent.ID, []string{child2.ID, child1.ID}) - if err != nil { - t.Fatalf("SortPages failed: %v", err) - } + parent := mustCreateNode(t, w, nil, "Parent", "parent", pageKind()) + child1 := mustCreateNode(t, w, &parent.ID, "Child1", "child1", pageKind()) + child2 := mustCreateNode(t, w, &parent.ID, "Child2", "child2", pageKind()) - // Check if the order is correct - sortedChildren := parent.Children - if sortedChildren[0].ID != child2.ID || sortedChildren[1].ID != child1.ID { - t.Errorf("Expected order [child2, child1], got [%s, %s]", sortedChildren[0].Slug, sortedChildren[1].Slug) + if err := w.SortPages(parent.ID, []string{child2.ID, child1.ID}); err != nil { + t.Fatalf("SortPages failed: %v", err) } -} - -func TestWiki_CopyPages(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - original, _ := w.CreatePage("system", nil, "Original", "original", pageNodeKind()) - copied, err := w.CopyPage("system", original.ID, nil, "Copy of Original", "copy-of-original") + updatedParent, err := w.GetPage(parent.ID) if err != nil { - t.Fatalf("CopyPage failed: %v", err) + t.Fatalf("GetPage failed: %v", err) } - if copied.Title != "Copy of Original" { - t.Errorf("Expected title 'Copy of Original', got %q", copied.Title) - } - if copied.Slug != "copy-of-original" { - t.Errorf("Expected slug 'copy-of-original', got %q", copied.Slug) + if len(updatedParent.Children) != 2 { + t.Fatalf("expected 2 children, got %d", len(updatedParent.Children)) } - if copied.ID == original.ID { - t.Error("Expected different ID for copied page") + if updatedParent.Children[0].ID != child2.ID || updatedParent.Children[1].ID != child1.ID { + t.Fatalf("expected order [child2, child1], got [%s, %s]", updatedParent.Children[0].Slug, updatedParent.Children[1].Slug) } } -func TestWiki_CopyPages_WithParent(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - parent, _ := w.CreatePage("system", nil, "Parent", "parent", pageNodeKind()) - original, _ := w.CreatePage("system", nil, "Original", "original", pageNodeKind()) +func TestWiki_CopyPage(t *testing.T) { + t.Run("simple copy", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - copied, err := w.CopyPage("system", original.ID, &parent.ID, "Copy of Original", "copy-of-original") - if err != nil { - t.Fatalf("CopyPage with parent failed: %v", err) - } + original := mustCreateNode(t, w, nil, "Original", "original", pageKind()) - if copied.Parent.ID != parent.ID { - t.Errorf("Expected parent ID %q, got %q", parent.ID, copied.Parent.ID) - } -} + copied, err := w.CopyPage("system", original.ID, nil, "Copy of Original", "copy-of-original") + if err != nil { + t.Fatalf("CopyPage failed: %v", err) + } -func TestWiki_CopyPages_NonExistentSource(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - _, err := w.CopyPage("system", "non-existent-id", nil, "Copy", "copy") - if err == nil { - t.Error("Expected error for non-existent source page, got none") - } -} + if copied.Title != "Copy of Original" { + t.Fatalf("expected title %q, got %q", "Copy of Original", copied.Title) + } + if copied.Slug != "copy-of-original" { + t.Fatalf("expected slug %q, got %q", "copy-of-original", copied.Slug) + } + if copied.ID == original.ID { + t.Fatalf("expected copied page to have different ID") + } + }) -func TestWiki_CopyPages_WithAssets(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - original, _ := w.CreatePage("system", nil, "Original", "original", pageNodeKind()) + t.Run("copy with parent", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - originalNode := tree.PageNode{ - ID: original.ID, - Title: original.Title, - Slug: original.Slug, - } + parent := mustCreateNode(t, w, nil, "Parent", "parent", pageKind()) + original := mustCreateNode(t, w, nil, "Original", "original", pageKind()) - file, _, err := test_utils.CreateMultipartFile("image.png", []byte("image content")) - if err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - defer test_utils.WrapCloseWithErrorCheck(file.Close, t) + copied, err := w.CopyPage("system", original.ID, &parent.ID, "Copy of Original", "copy-of-original") + if err != nil { + t.Fatalf("CopyPage failed: %v", err) + } - // Save asset for the original page - if _, err := w.GetAssetService().SaveAssetForPage(&originalNode, file, "image.png"); err != nil { - t.Fatalf("Failed to save asset for original page: %v", err) - } + if copied.Parent == nil || copied.Parent.ID != parent.ID { + t.Fatalf("expected parent ID %q, got %#v", parent.ID, copied.Parent) + } + }) - copied, err := w.CopyPage("system", original.ID, nil, "Copy of Original", "copy-of-original") - if err != nil { - t.Fatalf("CopyPage failed: %v", err) - } + t.Run("non existent source", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - copiedNode := tree.PageNode{ - ID: copied.ID, - Title: copied.Title, - Slug: copied.Slug, - } + _, err := w.CopyPage("system", "non-existent-id", nil, "Copy", "copy") + if err == nil { + t.Fatalf("expected error for missing source") + } + }) - // Check if the asset was copied - copiedAssetPath, err := w.GetAssetService().ListAssetsForPage(&copiedNode) - if err != nil { - t.Fatalf("Failed to list assets for copied page: %v", err) - } - if len(copiedAssetPath) != 1 { - t.Errorf("Expected 1 asset for copied page, got %d", len(copiedAssetPath)) - } + t.Run("copy with assets", func(t *testing.T) { + w := createWikiTestInstance(t) + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + + original := mustCreateNode(t, w, nil, "Original", "original", pageKind()) + + originalNode := tree.PageNode{ + ID: original.ID, + Title: original.Title, + Slug: original.Slug, + } + + file, _, err := test_utils.CreateMultipartFile("image.png", []byte("image content")) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + defer test_utils.WrapCloseWithErrorCheck(file.Close, t) + + if _, err := w.GetAssetService().SaveAssetForPage(&originalNode, file, "image.png"); err != nil { + t.Fatalf("Failed to save asset for original page: %v", err) + } + + copied, err := w.CopyPage("system", original.ID, nil, "Copy of Original", "copy-of-original") + if err != nil { + t.Fatalf("CopyPage failed: %v", err) + } + + copiedNode := tree.PageNode{ + ID: copied.ID, + Title: copied.Title, + Slug: copied.Slug, + } + + copiedAssets, err := w.GetAssetService().ListAssetsForPage(&copiedNode) + if err != nil { + t.Fatalf("Failed to list assets for copied page: %v", err) + } + if len(copiedAssets) != 1 { + t.Fatalf("expected 1 asset, got %d", len(copiedAssets)) + } + }) } func TestWiki_InitDefaultAdmin_UsesGivenPassword(t *testing.T) { @@ -445,12 +515,12 @@ func TestWiki_Login_SuccessAndFailure(t *testing.T) { token, err := w.Login("admin", "admin") if err != nil || token == nil { - t.Error("Expected login to succeed with default admin password") + t.Fatalf("expected login to succeed") } _, err = w.Login("admin", "wrong") if err == nil { - t.Error("Expected login to fail with wrong password") + t.Fatalf("expected login to fail with wrong password") } } @@ -458,19 +528,14 @@ func TestWiki_EnsurePath_HealsLinksForAllCreatedSegments(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - // 1) Create page A with links to /x and /x/y (both non-existing) - pageA, err := w.CreatePage("system", nil, "Page A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage A failed: %v", err) - } + pageA := mustCreateNode(t, w, nil, "Page A", "a", pageKind()) - var contentA = "Links: [X](/x) and [XY](/x/y)" - _, err = w.UpdatePage("system", pageA.ID, pageA.Title, pageA.Slug, &contentA, pageNodeKind()) + contentA := "Links: [X](/x) and [XY](/x/y)" + _, err := w.UpdatePage("system", pageA.ID, pageA.Title, pageA.Slug, &contentA, pageKind()) if err != nil { - t.Fatalf("UpdatePage A failed: %v", err) + t.Fatalf("UpdatePage failed: %v", err) } - // 2) Reindex once so that broken links are stored in the DB if err := w.ReindexLinks(); err != nil { t.Fatalf("ReindexLinks failed: %v", err) } @@ -480,72 +545,20 @@ func TestWiki_EnsurePath_HealsLinksForAllCreatedSegments(t *testing.T) { t.Fatalf("GetOutgoingLinks failed: %v", err) } if out1.Count != 2 { - t.Fatalf("expected 2 outgoings before ensure, got %d: %#v", out1.Count, out1.Outgoings) + t.Fatalf("expected 2 outgoings before ensure, got %d", out1.Count) } - byPath := map[string]bool{} - for _, it := range out1.Outgoings { - byPath[it.ToPath] = it.Broken - } - if broken, ok := byPath["/x"]; !ok || broken != true { - t.Fatalf("expected /x to be broken before ensure, got map=%#v, out=%#v", byPath, out1.Outgoings) - } - if broken, ok := byPath["/x/y"]; !ok || broken != true { - t.Fatalf("expected /x/y to be broken before ensure, got map=%#v, out=%#v", byPath, out1.Outgoings) - } - - // 3) EnsurePath creates /x and /x/y and triggers Heal for all newly created segments - _, err = w.EnsurePath("system", "/x/y", "X Y", pageNodeKind()) + _, err = w.EnsurePath("system", "/x/y", "X Y", pageKind()) if err != nil { t.Fatalf("EnsurePath failed: %v", err) } - // 4) Without reindexing: outgoing links from A should now be resolved out2, err := w.GetOutgoingLinks(pageA.ID) if err != nil { - t.Fatalf("GetOutgoingLinks (after ensure) failed: %v", err) + t.Fatalf("GetOutgoingLinks after ensure failed: %v", err) } if out2.Count != 2 { - t.Fatalf("expected 2 outgoings after ensure, got %d: %#v", out2.Count, out2.Outgoings) - } - - var gotX, gotXY *struct { - broken bool - toPage string - } - for _, it := range out2.Outgoings { - if it.ToPath == "/x" { - gotX = &struct { - broken bool - toPage string - }{it.Broken, it.ToPageID} - } - if it.ToPath == "/x/y" { - gotXY = &struct { - broken bool - toPage string - }{it.Broken, it.ToPageID} - } - } - - if gotX == nil { - t.Fatalf("missing outgoing to /x: %#v", out2.Outgoings) - } - if gotX.broken { - t.Fatalf("expected /x to be healed, got broken=true: %#v", out2.Outgoings) - } - if gotX.toPage == "" { - t.Fatalf("expected /x ToPageID to be set after heal, got empty: %#v", out2.Outgoings) - } - - if gotXY == nil { - t.Fatalf("missing outgoing to /x/y: %#v", out2.Outgoings) - } - if gotXY.broken { - t.Fatalf("expected /x/y to be healed, got broken=true: %#v", out2.Outgoings) - } - if gotXY.toPage == "" { - t.Fatalf("expected /x/y ToPageID to be set after heal, got empty: %#v", out2.Outgoings) + t.Fatalf("expected 2 outgoings after ensure, got %d", out2.Count) } } @@ -553,41 +566,28 @@ func TestWiki_DeletePage_NonRecursive_MarksIncomingBroken(t *testing.T) { w := createWikiTestInstance(t) defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - // Create A with link to /b - a, err := w.CreatePage("system", nil, "Page A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage A failed: %v", err) - } - - var contentA = "Link to B: [Go](/b)" - _, err = w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageNodeKind()) + a := mustCreateNode(t, w, nil, "Page A", "a", pageKind()) + contentA := "Link to B: [Go](/b)" + _, err := w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageKind()) if err != nil { t.Fatalf("UpdatePage A failed: %v", err) } - // Create B - b, err := w.CreatePage("system", nil, "Page B", "b", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage B failed: %v", err) - } - - var contentB = "# Page B" - _, err = w.UpdatePage("system", b.ID, b.Title, b.Slug, &contentB, pageNodeKind()) + b := mustCreateNode(t, w, nil, "Page B", "b", pageKind()) + contentB := "# Page B" + _, err = w.UpdatePage("system", b.ID, b.Title, b.Slug, &contentB, pageKind()) if err != nil { t.Fatalf("UpdatePage B failed: %v", err) } - // Ensure link index if err := w.ReindexLinks(); err != nil { t.Fatalf("ReindexLinks failed: %v", err) } - // Delete B if err := w.DeletePage("system", b.ID, false); err != nil { t.Fatalf("DeletePage failed: %v", err) } - // Outgoing links for A should still exist but be broken out, err := w.GetOutgoingLinks(a.ID) if err != nil { t.Fatalf("GetOutgoingLinks failed: %v", err) @@ -595,697 +595,109 @@ func TestWiki_DeletePage_NonRecursive_MarksIncomingBroken(t *testing.T) { if out.Count != 1 { t.Fatalf("expected 1 outgoing, got %d", out.Count) } - - got := out.Outgoings[0] - if got.ToPath != "/b" { - t.Fatalf("ToPath = %q, want %q", got.ToPath, "/b") + if out.Outgoings[0].ToPath != "/b" { + t.Fatalf("expected ToPath /b, got %q", out.Outgoings[0].ToPath) } - if got.Broken != true { - t.Fatalf("Broken = %v, want true", got.Broken) + if !out.Outgoings[0].Broken { + t.Fatalf("expected outgoing link to be broken") } - if got.ToPageID != "" { - t.Fatalf("ToPageID = %q, want empty", got.ToPageID) - } - - // Backlinks for B must be 0 because query filters on broken=0/to_page_id match - bl, err := w.GetBacklinks(b.ID) - if err != nil { - t.Fatalf("GetBacklinks failed: %v", err) - } - if bl.Count != 0 { - t.Fatalf("expected 0 backlinks after delete, got %d", bl.Count) - } -} - -func TestWiki_DeletePage_Recursive_RemovesOutgoingForSubtree_AndBreaksIncomingByPrefix(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - - // Create /docs - docs, err := w.CreatePage("system", nil, "Docs", "docs", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage docs failed: %v", err) - } - - // Create /docs/a and /docs/b - a, err := w.CreatePage("system", &docs.ID, "A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage a failed: %v", err) - } - b, err := w.CreatePage("system", &docs.ID, "B", "b", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage b failed: %v", err) - } - - // A links to B inside subtree - var contentA = "Link to B: [B](/docs/b)" - _, err = w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage a failed: %v", err) - } - var contentB = "# B" - _, err = w.UpdatePage("system", b.ID, b.Title, b.Slug, &contentB, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage b failed: %v", err) - } - - // Create survivor /c with incoming link into subtree - c, err := w.CreatePage("system", nil, "C", "c", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage c failed: %v", err) - } - var contentC = "Incoming link: [B](/docs/b)" - _, err = w.UpdatePage("system", c.ID, c.Title, c.Slug, &contentC, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage c failed: %v", err) - } - - if err := w.ReindexLinks(); err != nil { - t.Fatalf("ReindexLinks failed: %v", err) - } - - // Sanity check: A has an outgoing link before delete - outA, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(a) before delete failed: %v", err) - } - if outA.Count != 1 { - t.Fatalf("expected 1 outgoing from a before delete, got %d", outA.Count) - } - - // Delete /docs recursively - if err := w.DeletePage("system", docs.ID, true); err != nil { - t.Fatalf("DeletePage(docs, recursive) failed: %v", err) - } - - // 1) Outgoing links FROM deleted child page must be gone - outAAfter, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(a) after delete failed: %v", err) - } - if outAAfter.Count != 0 { - t.Fatalf("expected 0 outgoing from deleted page a, got %d", outAAfter.Count) - } - - // 2) Incoming link from survivor page /c into subtree must still exist, but be broken - outC, err := w.GetOutgoingLinks(c.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(c) after delete failed: %v", err) - } - if outC.Count != 1 { - t.Fatalf("expected 1 outgoing from c, got %d", outC.Count) - } - - got := outC.Outgoings[0] - if got.ToPath != "/docs/b" { - t.Fatalf("ToPath = %q, want %q", got.ToPath, "/docs/b") - } - if got.Broken != true { - t.Fatalf("Broken = %v, want true", got.Broken) - } - if got.ToPageID != "" { - t.Fatalf("ToPageID = %q, want empty", got.ToPageID) - } -} - -func TestWiki_RenamePage_MarksOldBroken_HealsNewExactPath(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - - // Create A with links to /b (exists) and /b2 (does not exist yet) - a, err := w.CreatePage("system", nil, "A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage A failed: %v", err) - } - var contentA = "Links: [B](/b) and [B2](/b2)" - _, err = w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage A failed: %v", err) - } - - // Create B at /b - b, err := w.CreatePage("system", nil, "B", "b", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage B failed: %v", err) - } - var contentB = "# B" - _, err = w.UpdatePage("system", b.ID, b.Title, b.Slug, &contentB, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage B failed: %v", err) - } - - // Index once so outgoing links exist + broken state is materialized - if err := w.ReindexLinks(); err != nil { - t.Fatalf("ReindexLinks failed: %v", err) - } - - out1, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(A) failed: %v", err) - } - if out1.Count != 2 { - t.Fatalf("expected 2 outgoings before rename, got %d: %#v", out1.Count, out1.Outgoings) - } - - byPath1 := map[string]struct { - broken bool - toID string - }{} - for _, it := range out1.Outgoings { - byPath1[it.ToPath] = struct { - broken bool - toID string - }{it.Broken, it.ToPageID} - } - - if got, ok := byPath1["/b"]; !ok || got.broken { - t.Fatalf("expected /b to be valid before rename, got %#v", byPath1) - } - if got, ok := byPath1["/b2"]; !ok || !got.broken { - t.Fatalf("expected /b2 to be broken before rename, got %#v", byPath1) - } - - // Rename B: /b -> /b2 - var contentB2 = "# B (renamed)" - _, err = w.UpdatePage("system", b.ID, b.Title, "b2", &contentB2, pageNodeKind()) - if err != nil { - t.Fatalf("Rename B failed: %v", err) - } - - // Without reindex: outgoing from A should reflect: - // - /b becomes broken - // - /b2 becomes healed and points to B's ID - out2, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(A) after rename failed: %v", err) - } - if out2.Count != 2 { - t.Fatalf("expected 2 outgoings after rename, got %d: %#v", out2.Count, out2.Outgoings) - } - - byPath2 := map[string]struct { - broken bool - toID string - }{} - for _, it := range out2.Outgoings { - byPath2[it.ToPath] = struct { - broken bool - toID string - }{it.Broken, it.ToPageID} - } - - // old path broken - if got, ok := byPath2["/b"]; !ok || !got.broken || got.toID != "" { - t.Fatalf("expected /b to be broken with empty to_page_id after rename, got %#v", byPath2) - } - - // new path healed - gotNew, ok := byPath2["/b2"] - if !ok || gotNew.broken || gotNew.toID == "" { - t.Fatalf("expected /b2 to be healed with to_page_id set, got %#v", byPath2) - } - if gotNew.toID != b.ID { - t.Fatalf("expected /b2 to resolve to page %q, got %q", b.ID, gotNew.toID) - } -} - -func TestWiki_RenameSubtree_BreaksOldPrefix_HealsNewSubpaths(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - - // Create subtree: /docs/b - docs, err := w.CreatePage("system", nil, "Docs", "docs", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage docs failed: %v", err) - } - b, err := w.CreatePage("system", &docs.ID, "B", "b", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage /docs/b failed: %v", err) - } - var contentB = "# B" - _, err = w.UpdatePage("system", b.ID, b.Title, b.Slug, &contentB, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage B failed: %v", err) - } - - // Create A that links to old and future new subtree paths - a, err := w.CreatePage("system", nil, "A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage A failed: %v", err) - } - var contentA = "Links: [Old](/docs/b) and [New](/docs2/b)" - _, err = w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage A failed: %v", err) - } - - // Materialize graph state - if err := w.ReindexLinks(); err != nil { - t.Fatalf("ReindexLinks failed: %v", err) - } - - out1, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(A) failed: %v", err) - } - if out1.Count != 2 { - t.Fatalf("expected 2 outgoings before rename, got %d: %#v", out1.Count, out1.Outgoings) - } - - // Rename /docs -> /docs2 - var contentDocs2 = "# Docs" - nodeSection := tree.NodeKindSection - _, err = w.UpdatePage("system", docs.ID, docs.Title, "docs2", &contentDocs2, &nodeSection) - if err != nil { - t.Fatalf("Rename docs failed: %v", err) - } - - // Without reindex: A should now have - // - /docs/b broken - // - /docs2/b healed and resolves to the same page id as the child B - out2, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(A) after subtree rename failed: %v", err) - } - if out2.Count != 2 { - t.Fatalf("expected 2 outgoings after rename, got %d: %#v", out2.Count, out2.Outgoings) - } - - byPath := map[string]struct { - broken bool - toID string - }{} - for _, it := range out2.Outgoings { - byPath[it.ToPath] = struct { - broken bool - toID string - }{it.Broken, it.ToPageID} - } - - // old prefix path broken - if got, ok := byPath["/docs/b"]; !ok || !got.broken || got.toID != "" { - t.Fatalf("expected /docs/b broken with empty to_page_id, got %#v", byPath) - } - - // new subpath healed - gotNew, ok := byPath["/docs2/b"] - if !ok || gotNew.broken || gotNew.toID == "" { - t.Fatalf("expected /docs2/b healed with to_page_id set, got %#v", byPath) - } - if gotNew.toID != b.ID { - t.Fatalf("expected /docs2/b to resolve to page %q, got %q", b.ID, gotNew.toID) - } -} - -func TestWiki_MovePage_MarksOldBroken_HealsNewExactPath(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - - // Create A that links to /b (old path) and /projects/b (future path) - a, err := w.CreatePage("system", nil, "A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage A failed: %v", err) - } - var contentA = "Links: [B](/b) and [B2](/projects/b)" - _, err = w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage A failed: %v", err) - } - - // Create B at /b - b, err := w.CreatePage("system", nil, "B", "b", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage B failed: %v", err) - } - var contentB = "# B" - _, err = w.UpdatePage("system", b.ID, b.Title, b.Slug, &contentB, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage B failed: %v", err) - } - - // Create parent /projects (target) - projects, err := w.CreatePage("system", nil, "Projects", "projects", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage projects failed: %v", err) - } - - // Materialize links once (so broken links exist in DB) - if err := w.ReindexLinks(); err != nil { - t.Fatalf("ReindexLinks failed: %v", err) - } - - // Sanity: /b should be valid, /projects/b should be broken before move - out1, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(A) failed: %v", err) - } - if out1.Count != 2 { - t.Fatalf("expected 2 outgoings before move, got %d: %#v", out1.Count, out1.Outgoings) - } - - state1 := map[string]struct { - broken bool - toID string - }{} - for _, it := range out1.Outgoings { - state1[it.ToPath] = struct { - broken bool - toID string - }{it.Broken, it.ToPageID} - } - - if got := state1["/b"]; got.broken || got.toID == "" { - t.Fatalf("expected /b valid before move, got %#v", state1) - } - if got := state1["/projects/b"]; !got.broken || got.toID != "" { - t.Fatalf("expected /projects/b broken before move, got %#v", state1) - } - - // Move B under /projects => /projects/b now exists - if err := w.MovePage("system", b.ID, projects.ID); err != nil { - t.Fatalf("MovePage failed: %v", err) - } - - // Without reindex: /b must become broken, /projects/b must be healed to B - out2, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(A) after move failed: %v", err) - } - if out2.Count != 2 { - t.Fatalf("expected 2 outgoings after move, got %d: %#v", out2.Count, out2.Outgoings) - } - - state2 := map[string]struct { - broken bool - toID string - }{} - for _, it := range out2.Outgoings { - state2[it.ToPath] = struct { - broken bool - toID string - }{it.Broken, it.ToPageID} - } - - // old path broken - if got := state2["/b"]; !got.broken || got.toID != "" { - t.Fatalf("expected /b broken after move (to_page_id empty), got %#v", state2) - } - - // new path healed - if got := state2["/projects/b"]; got.broken || got.toID != b.ID { - t.Fatalf("expected /projects/b healed to page %q, got %#v", b.ID, state2) + if out.Outgoings[0].ToPageID != "" { + t.Fatalf("expected empty ToPageID, got %q", out.Outgoings[0].ToPageID) } } -func TestWiki_MoveSubtree_BreaksOldPrefix_HealsNewSubpaths(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - - // Create subtree /docs/b - docs, err := w.CreatePage("system", nil, "Docs", "docs", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage docs failed: %v", err) - } - b, err := w.CreatePage("system", &docs.ID, "B", "b", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage /docs/b failed: %v", err) - } - var contentB = "# B" - _, err = w.UpdatePage("system", b.ID, b.Title, b.Slug, &contentB, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage B failed: %v", err) - } - - // Create target parent /archive - archive, err := w.CreatePage("system", nil, "Archive", "archive", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage archive failed: %v", err) - } - - // Create A that links to old and future new subtree paths - a, err := w.CreatePage("system", nil, "A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage A failed: %v", err) - } - var contentA = "Links: [Old](/docs/b) and [New](/archive/docs/b)" - _, err = w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage A failed: %v", err) - } - - // Materialize graph - if err := w.ReindexLinks(); err != nil { - t.Fatalf("ReindexLinks failed: %v", err) - } - - // Move /docs under /archive => /archive/docs/b exists, /docs/b disappears - if err := w.MovePage("system", docs.ID, archive.ID); err != nil { - t.Fatalf("MovePage(docs -> archive) failed: %v", err) - } - - // Without reindex: A should now have /docs/b broken and /archive/docs/b healed to the same B id - out, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(A) after subtree move failed: %v", err) - } - if out.Count != 2 { - t.Fatalf("expected 2 outgoings after move, got %d: %#v", out.Count, out.Outgoings) - } - - state := map[string]struct { - broken bool - toID string - }{} - for _, it := range out.Outgoings { - state[it.ToPath] = struct { - broken bool - toID string - }{it.Broken, it.ToPageID} - } - - if got := state["/docs/b"]; !got.broken || got.toID != "" { - t.Fatalf("expected /docs/b broken after move, got %#v", state) - } - - if got := state["/archive/docs/b"]; got.broken || got.toID != b.ID { - t.Fatalf("expected /archive/docs/b healed to page %q, got %#v", b.ID, state) - } -} - -func TestWiki_MovePage_ReindexesRelativeLinks(t *testing.T) { - w := createWikiTestInstance(t) - defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - - // Create /docs with /docs/shared and /docs/a - docs, err := w.CreatePage("system", nil, "Docs", "docs", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage docs failed: %v", err) - } - - docsShared, err := w.CreatePage("system", &docs.ID, "Shared", "shared", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage /docs/shared failed: %v", err) - } - var contentDocsShared = "# Docs Shared" - _, err = w.UpdatePage("system", docsShared.ID, docsShared.Title, docsShared.Slug, &contentDocsShared, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage /docs/shared failed: %v", err) - } - - a, err := w.CreatePage("system", &docs.ID, "A", "a", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage /docs/a failed: %v", err) - } - // Important: relative link - var contentA = "Relative: [S](../shared)" - _, err = w.UpdatePage("system", a.ID, a.Title, a.Slug, &contentA, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage /docs/a failed: %v", err) - } - - // Create /guide with /guide/shared (different page!) - guide, err := w.CreatePage("system", nil, "Guide", "guide", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage guide failed: %v", err) - } - - guideShared, err := w.CreatePage("system", &guide.ID, "Shared", "shared", pageNodeKind()) - if err != nil { - t.Fatalf("CreatePage /guide/shared failed: %v", err) - } - var contentGuideShared = "# Guide Shared" - _, err = w.UpdatePage("system", guideShared.ID, guideShared.Title, guideShared.Slug, &contentGuideShared, pageNodeKind()) - if err != nil { - t.Fatalf("UpdatePage /guide/shared failed: %v", err) - } - - // Materialize graph - if err := w.ReindexLinks(); err != nil { - t.Fatalf("ReindexLinks failed: %v", err) - } - - // Before move: /docs/a's outgoing must resolve to /docs/shared - out1, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(/docs/a) before move failed: %v", err) - } - if out1.Count != 1 { - t.Fatalf("expected 1 outgoing before move, got %d: %#v", out1.Count, out1.Outgoings) - } - if out1.Outgoings[0].ToPath != "/docs/shared" { - t.Fatalf("ToPath before move = %q, want %q", out1.Outgoings[0].ToPath, "/docs/shared") - } - if out1.Outgoings[0].Broken { - t.Fatalf("expected link to be valid before move, got broken=true") - } - if out1.Outgoings[0].ToPageID != docsShared.ID { - t.Fatalf("ToPageID before move = %q, want %q", out1.Outgoings[0].ToPageID, docsShared.ID) - } - - // Move /docs/a under /guide => page path becomes /guide/a - if err := w.MovePage("system", a.ID, guide.ID); err != nil { - t.Fatalf("MovePage(a -> guide) failed: %v", err) - } - - // After move (without reindex): relative link must now resolve to /guide/shared - out2, err := w.GetOutgoingLinks(a.ID) - if err != nil { - t.Fatalf("GetOutgoingLinks(/guide/a) after move failed: %v", err) - } - if out2.Count != 1 { - t.Fatalf("expected 1 outgoing after move, got %d: %#v", out2.Count, out2.Outgoings) - } - if out2.Outgoings[0].ToPath != "/guide/shared" { - t.Fatalf("ToPath after move = %q, want %q", out2.Outgoings[0].ToPath, "/guide/shared") - } - if out2.Outgoings[0].Broken { - t.Fatalf("expected link to be valid after move, got broken=true") - } - if out2.Outgoings[0].ToPageID != guideShared.ID { - t.Fatalf("ToPageID after move = %q, want %q", out2.Outgoings[0].ToPageID, guideShared.ID) - } -} +func TestWiki_AuthDisabled(t *testing.T) { + t.Run("auth service is nil", func(t *testing.T) { + w, err := NewWiki(&WikiOptions{ + StorageDir: t.TempDir(), + AuthDisabled: true, + }) + if err != nil { + t.Fatalf("Failed to create wiki instance: %v", err) + } + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) -func TestWiki_AuthDisabled_Initialization(t *testing.T) { - // Create a wiki instance with AuthDisabled set to true - wikiInstance, err := NewWiki(&WikiOptions{ - StorageDir: t.TempDir(), - AdminPassword: "", - JWTSecret: "", - AccessTokenTimeout: 0, - RefreshTokenTimeout: 0, - AuthDisabled: true, + if w.GetAuthService() != nil { + t.Fatalf("expected auth service to be nil") + } }) - if err != nil { - t.Fatalf("Failed to create wiki instance with AuthDisabled: %v", err) - } - defer test_utils.WrapCloseWithErrorCheck(wikiInstance.Close, t) - // Verify that the auth service is nil - if wikiInstance.GetAuthService() != nil { - t.Error("Expected auth service to be nil when AuthDisabled is true") - } -} + t.Run("login logout refresh return ErrAuthDisabled", func(t *testing.T) { + w, err := NewWiki(&WikiOptions{ + StorageDir: t.TempDir(), + AuthDisabled: true, + }) + if err != nil { + t.Fatalf("Failed to create wiki instance: %v", err) + } + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) + + tests := []struct { + name string + run func() error + }{ + { + name: "login", + run: func() error { + _, err := w.Login("admin", "admin") + return err + }, + }, + { + name: "logout", + run: func() error { + return w.Logout("some-token") + }, + }, + { + name: "refresh", + run: func() error { + _, err := w.RefreshToken("some-token") + return err + }, + }, + } -func TestWiki_AuthDisabled_LoginReturnsError(t *testing.T) { - // Create a wiki instance with AuthDisabled set to true - wikiInstance, err := NewWiki(&WikiOptions{ - StorageDir: t.TempDir(), - AuthDisabled: true, + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if err := tc.run(); err != ErrAuthDisabled { + t.Fatalf("expected ErrAuthDisabled, got %v", err) + } + }) + } }) - if err != nil { - t.Fatalf("Failed to create wiki instance with AuthDisabled: %v", err) - } - defer test_utils.WrapCloseWithErrorCheck(wikiInstance.Close, t) - // Attempt to login should return ErrAuthDisabled - _, err = wikiInstance.Login("admin", "admin") - if err != ErrAuthDisabled { - t.Errorf("Expected ErrAuthDisabled, got %v", err) - } -} - -func TestWiki_AuthDisabled_LogoutReturnsError(t *testing.T) { - // Create a wiki instance with AuthDisabled set to true - wikiInstance, err := NewWiki(&WikiOptions{ - StorageDir: t.TempDir(), - AuthDisabled: true, - }) - if err != nil { - t.Fatalf("Failed to create wiki instance with AuthDisabled: %v", err) - } - defer test_utils.WrapCloseWithErrorCheck(wikiInstance.Close, t) + t.Run("core functionality still works", func(t *testing.T) { + w, err := NewWiki(&WikiOptions{ + StorageDir: t.TempDir(), + AuthDisabled: true, + }) + if err != nil { + t.Fatalf("Failed to create wiki instance: %v", err) + } + defer test_utils.WrapCloseWithErrorCheck(w.Close, t) - // Attempt to logout should return ErrAuthDisabled - err = wikiInstance.Logout("some-token") - if err != ErrAuthDisabled { - t.Errorf("Expected ErrAuthDisabled, got %v", err) - } -} + page := mustCreateNode(t, w, nil, "Test Page", "test-page", pageKind()) -func TestWiki_AuthDisabled_RefreshTokenReturnsError(t *testing.T) { - // Create a wiki instance with AuthDisabled set to true - wikiInstance, err := NewWiki(&WikiOptions{ - StorageDir: t.TempDir(), - AuthDisabled: true, - }) - if err != nil { - t.Fatalf("Failed to create wiki instance with AuthDisabled: %v", err) - } - defer test_utils.WrapCloseWithErrorCheck(wikiInstance.Close, t) + content := "# Content" + updatedPage, err := w.UpdatePage("system", page.ID, "Updated Title", "updated-slug", &content, pageKind()) + if err != nil { + t.Fatalf("UpdatePage failed: %v", err) + } + if updatedPage.Title != "Updated Title" { + t.Fatalf("expected title %q, got %q", "Updated Title", updatedPage.Title) + } - // Attempt to refresh token should return ErrAuthDisabled - _, err = wikiInstance.RefreshToken("some-token") - if err != ErrAuthDisabled { - t.Errorf("Expected ErrAuthDisabled, got %v", err) - } -} + retrievedPage, err := w.GetPage(page.ID) + if err != nil { + t.Fatalf("GetPage failed: %v", err) + } + if retrievedPage.ID != page.ID { + t.Fatalf("expected ID %q, got %q", page.ID, retrievedPage.ID) + } -func TestWiki_AuthDisabled_CoreFunctionalityWorks(t *testing.T) { - // Create a wiki instance with AuthDisabled set to true - wikiInstance, err := NewWiki(&WikiOptions{ - StorageDir: t.TempDir(), - AuthDisabled: true, + if err := w.DeletePage("system", page.ID, false); err != nil { + t.Fatalf("DeletePage failed: %v", err) + } }) - if err != nil { - t.Fatalf("Failed to create wiki instance with AuthDisabled: %v", err) - } - defer test_utils.WrapCloseWithErrorCheck(wikiInstance.Close, t) - - // Test creating a page - page, err := wikiInstance.CreatePage("system", nil, "Test Page", "test-page", pageNodeKind()) - if err != nil { - t.Fatalf("Failed to create page with AuthDisabled: %v", err) - } - - if page.Title != "Test Page" { - t.Errorf("Expected title 'Test Page', got %q", page.Title) - } - - // Test updating a page - var updatedContent = "# Content" - updatedPage, err := wikiInstance.UpdatePage("system", page.ID, "Updated Title", "updated-slug", &updatedContent, pageNodeKind()) - if err != nil { - t.Fatalf("Failed to update page with AuthDisabled: %v", err) - } - - if updatedPage.Title != "Updated Title" { - t.Errorf("Expected title 'Updated Title', got %q", updatedPage.Title) - } - - // Test getting a page - retrievedPage, err := wikiInstance.GetPage(page.ID) - if err != nil { - t.Fatalf("Failed to get page with AuthDisabled: %v", err) - } - - if retrievedPage.ID != page.ID { - t.Errorf("Expected ID %q, got %q", page.ID, retrievedPage.ID) - } - - // Test deleting a page - err = wikiInstance.DeletePage("system", page.ID, false) - if err != nil { - t.Fatalf("Failed to delete page with AuthDisabled: %v", err) - } } From dfe25696ad1c7b75c9f760659e3a1b5f8155a44d Mon Sep 17 00:00:00 2001 From: Patrick Erber Date: Sun, 15 Mar 2026 18:03:16 +0100 Subject: [PATCH 5/7] feat: remove reconstruct tree As we rely on the FS it is not necessary to keep it anylonger --- cmd/leafwiki/main.go | 17 ----------------- internal/core/tools/reconstruct_tree.go | 10 ---------- 2 files changed, 27 deletions(-) delete mode 100644 internal/core/tools/reconstruct_tree.go diff --git a/cmd/leafwiki/main.go b/cmd/leafwiki/main.go index 68aab4d8..d0fe1c2d 100644 --- a/cmd/leafwiki/main.go +++ b/cmd/leafwiki/main.go @@ -20,7 +20,6 @@ func printUsage() { leafwiki --jwt-secret --admin-password [--host ] [--port ] [--data-dir ] leafwiki --disable-auth [--host ] [--port ] [--data-dir ] leafwiki reset-admin-password - leafwiki [--data-dir ] reconstruct-tree leafwiki --help Options: @@ -130,22 +129,6 @@ func main() { fmt.Println("Admin password reset successfully.") fmt.Printf("New password for user %s: %s\n", user.Username, user.Password) return - case "reconstruct-tree": - // Ensure data directory exists before reconstruction - if _, err := os.Stat(dataDir); err != nil { - if os.IsNotExist(err) { - if err := os.MkdirAll(dataDir, 0755); err != nil { - fail("Failed to create data directory", "error", err) - } - } else { - fail("Failed to access data directory", "error", err) - } - } - if err := tools.ReconstructTreeFromFS(dataDir); err != nil { - fail("Tree reconstruction failed", "error", err) - } - fmt.Println("Tree reconstructed successfully from filesystem.") - return case "--help", "-h", "help": printUsage() return diff --git a/internal/core/tools/reconstruct_tree.go b/internal/core/tools/reconstruct_tree.go deleted file mode 100644 index 726dc9b3..00000000 --- a/internal/core/tools/reconstruct_tree.go +++ /dev/null @@ -1,10 +0,0 @@ -package tools - -import ( - "github.com/perber/wiki/internal/core/tree" -) - -func ReconstructTreeFromFS(storageDir string) error { - treeService := tree.NewTreeService(storageDir) - return treeService.ReconstructTreeFromFS() -} From 8bcd025c8bef5366a664aa156579dc96a0e85288 Mon Sep 17 00:00:00 2001 From: Patrick Erber Date: Sun, 15 Mar 2026 18:07:43 +0100 Subject: [PATCH 6/7] feat: update importer --- internal/importer/executor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/importer/executor.go b/internal/importer/executor.go index 6fe3e017..727d70ff 100644 --- a/internal/importer/executor.go +++ b/internal/importer/executor.go @@ -104,7 +104,7 @@ func (e *Executor) Execute(userID string) (*ExecutionResult, error) { continue } body := mdFile.GetContent() - if _, err := e.wiki.UpdatePage(userID, page.ID, page.Title, page.Slug, &body, &page.Kind); err != nil { + if _, err := e.wiki.UpdatePage(userID, page.ID, page.Title, page.Slug, &body, nil); err != nil { errMsg := err.Error() execItem.Action = ExecutionActionSkipped execItem.Error = &errMsg From 164c0ba9ebaae364cfd6caa04453f5d8a74d456f Mon Sep 17 00:00:00 2001 From: Patrick Erber Date: Sun, 15 Mar 2026 18:12:47 +0100 Subject: [PATCH 7/7] docs: remove reconstruct tree block --- readme.md | 67 ------------------------------------------------------- 1 file changed, 67 deletions(-) diff --git a/readme.md b/readme.md index aa101317..00baa490 100644 --- a/readme.md +++ b/readme.md @@ -374,73 +374,6 @@ If you use this flag, **you are fully responsible for securing access at the net --- -## Reconstruct Tree from Filesystem - -LeafWiki includes a built-in `reconstruct-tree` command that rebuilds the navigation tree (`tree.json`) by scanning the actual Markdown files and folders on disk. - -**Usage:** - -```bash -leafwiki [--data-dir ] reconstruct-tree -``` - -Or if you installed LeafWiki as a binary: - -```bash -./leafwiki [--data-dir ] reconstruct-tree -``` - -**What it does:** - -The command: -- Scans the `data/root` directory recursively -- Extracts page titles from Markdown files (from H1 headings or frontmatter) -- Preserves `leafwiki_id` values from frontmatter when present -- Generates new IDs for pages without frontmatter IDs -- Rebuilds the complete navigation tree structure -- Saves the new `tree.json` and updates `schema.json` - -**Use cases:** - -1. **Recovery from corrupted tree.json** - If your `tree.json` becomes corrupted or deleted, this command reconstructs it from your existing Markdown files. - -2. **Manual filesystem changes** - If you've added, moved, or renamed Markdown files directly on disk (outside LeafWiki's UI), run this command to sync the navigation tree. - -3. **Migration and import** - When importing existing Markdown content into LeafWiki, use this command to automatically generate the navigation structure. - -4. **Tree structure reset** - If the tree structure becomes inconsistent with the filesystem, this provides a clean rebuild based on actual file layout. - -**Important notes:** - -- ⚠️ This command **replaces the entire tree structure**. Any custom ordering or metadata in `tree.json` will be lost. -- The command creates a **deterministic, alphabetically-sorted** tree based on file and folder names. -- Page content (Markdown files) is never modified—only the navigation structure is rebuilt. -- Frontmatter `leafwiki_id` values are preserved when present, maintaining page identity and internal links. -- For folders (sections), the command looks for `index.md` to extract the section title. -- Files and folders starting with `.` (hidden) are automatically skipped. - -**Example:** - -```bash -# Default data directory (./data) -leafwiki reconstruct-tree - -# Custom data directory -leafwiki --data-dir /path/to/data reconstruct-tree -``` - -**Before running this command:** - -- Ensure your data directory exists and contains a `root` folder with your Markdown content -- Consider backing up your current `tree.json` if you need to preserve custom ordering -- The server does not need to be running—this is a standalone command - ---- - ## Import Feature LeafWiki includes a built-in Markdown Importer that allows you to import existing Markdown files and folders into the wiki structure.