munin/internal/export/export_test.go
2026-10-19 10:13:56 +02:00

404 lines
13 KiB
Go

package export
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"munin/internal/store"
)
// setup legt Datenbank, Projekt und Exporter an.
func setup(t *testing.T) (*Exporter, *store.Store, store.Project, string) {
t.Helper()
db, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("Datenbank: %v", err)
}
t.Cleanup(func() { db.Close() })
ctx := context.Background()
// Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr — die lebt in
// der zentralen Kontroll-Datenbank (siehe internal/store/control.go). Für
// den Test reicht eine beliebige ID.
workspaceID := store.NewID()
project, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams")
if err != nil {
t.Fatal(err)
}
// Der Exporter schreibt unter <root>/<workspace-id>/. Zurückgegeben wird
// gleich der Workspace-Ordner, damit die Tests Pfade wie
// ziel/plattform/eintrag.md prüfen können, ohne die ID zu kennen.
root := t.TempDir()
return New(root), db, project, filepath.Join(root, workspaceID)
}
// readFile gibt den Inhalt einer exportierten Datei zurück.
func readFile(t *testing.T, parts ...string) string {
t.Helper()
content, err := os.ReadFile(filepath.Join(parts...))
if err != nil {
t.Fatalf("Datei nicht lesbar: %v", err)
}
return string(content)
}
func TestExportWritesEntries(t *testing.T) {
e, db, project, target := setup(t)
ctx := context.Background()
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, Slug: "deployment", Title: "Deployment-Prozess",
Content: "Wir deployen über GitHub Actions.", Tags: []string{"ops", "deployment"},
Source: store.SourceAgent, Author: "claude", Curated: true,
}); err != nil {
t.Fatal(err)
}
stats, err := e.ExportProject(ctx, db, project)
if err != nil {
t.Fatalf("Export: %v", err)
}
// Ein Eintrag plus Übersicht.
if stats.Written != 2 {
t.Errorf("%d Dateien geschrieben, erwartet 2", stats.Written)
}
content := readFile(t, target, "plattform", "deployment.md")
// Der Kopf muss alle Felder tragen, damit sich der Bestand daraus auch
// wieder einlesen ließe.
for _, want := range []string{
`slug: "deployment"`,
`title: "Deployment-Prozess"`,
`tags: ["deployment", "ops"]`,
`source: "agent"`,
`author: "claude"`,
"curated: true",
"version: 1",
"created_at:",
"updated_at:",
} {
if !strings.Contains(content, want) {
t.Errorf("%q fehlt im Dateikopf:\n%s", want, content)
}
}
if !strings.Contains(content, "Wir deployen über GitHub Actions.") {
t.Error("der Inhalt fehlt")
}
}
// TestExportEscapesYAML prüft, dass Sonderzeichen den Dateikopf nicht zerlegen.
//
// Ein Titel wie "Fehler: Zugriff verweigert" enthält einen Doppelpunkt und würde
// ohne Anführungszeichen die YAML-Struktur zerstören.
func TestExportEscapesYAML(t *testing.T) {
e, db, project, target := setup(t)
ctx := context.Background()
sensitive := `Fehler: "Zugriff verweigert" — siehe #42`
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID, Slug: "heikel", Title: sensitive,
Content: "Text", Source: store.SourceHuman,
}); err != nil {
t.Fatal(err)
}
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
content := readFile(t, target, "plattform", "heikel.md")
// Der Wert muss in Anführungszeichen stehen und die enthaltenen maskiert sein.
if !strings.Contains(content, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) {
t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", content)
}
// Der Kopf muss weiterhin genau zwei Trennlinien haben.
if count := strings.Count(content, "\n---\n"); count != 1 {
headEnd := strings.Index(content[4:], "---")
if headEnd == -1 {
t.Error("der Dateikopf wurde nicht sauber abgeschlossen")
}
}
}
// TestExportRemovesOrphans prüft, dass gelöschte Einträge auch aus dem Export
// verschwinden — sonst wäre die Sicherung nach kurzer Zeit voller Karteileichen.
func TestExportRemovesOrphans(t *testing.T) {
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "bleibt", Title: "Bleibt",
Content: "x", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "geht", Title: "Geht",
Content: "x", Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(target, "plattform", "geht.md")); err != nil {
t.Fatal("die Datei wurde gar nicht erst angelegt")
}
// Ein Eintrag im Papierkorb gehört nicht mehr in den Export.
if err := db.MoveEntryToTrash(ctx, project.ID, "geht", "test"); err != nil {
t.Fatal(err)
}
stats, err := e.ExportProject(ctx, db, project)
if err != nil {
t.Fatal(err)
}
if stats.Removed != 1 {
t.Errorf("%d Dateien entfernt, erwartet 1", stats.Removed)
}
if _, err := os.Stat(filepath.Join(target, "plattform", "geht.md")); err == nil {
t.Error("die Datei des gelöschten Eintrags liegt noch da")
}
if _, err := os.Stat(filepath.Join(target, "plattform", "bleibt.md")); err != nil {
t.Error("beim Aufräumen wurde zu viel gelöscht")
}
}
// TestExportLeavesForeignFilesAlone prüft, dass eigene Dateien im Zielordner
// nicht angetastet werden.
func TestExportLeavesForeignFilesAlone(t *testing.T) {
e, db, project, target := setup(t)
ctx := context.Background()
dir := filepath.Join(target, "plattform")
if err := os.MkdirAll(dir, 0750); err != nil {
t.Fatal(err)
}
// Eine Nicht-Markdown-Datei und ein Unterordner müssen überleben.
os.WriteFile(filepath.Join(dir, "notizen.txt"), []byte("privat"), 0640)
os.MkdirAll(filepath.Join(dir, "anhaenge"), 0750)
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "notizen.txt")); err != nil {
t.Error("eine fremde Datei wurde entfernt")
}
if _, err := os.Stat(filepath.Join(dir, "anhaenge")); err != nil {
t.Error("ein Unterordner wurde entfernt")
}
}
// TestExportIsStable prüft, dass ein zweiter Lauf ohne Änderungen die Dateien
// nicht anfasst — sonst meldeten Sicherungswerkzeuge ständig Bewegung.
func TestExportIsStable(t *testing.T) {
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "eintrag", Title: "Eintrag",
Content: "Inhalt", Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
filePath := filepath.Join(target, "plattform", "eintrag.md")
before, err := os.Stat(filePath)
if err != nil {
t.Fatal(err)
}
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
after, err := os.Stat(filePath)
if err != nil {
t.Fatal(err)
}
if !before.ModTime().Equal(after.ModTime()) {
t.Error("die unveränderte Datei wurde neu geschrieben")
}
}
// TestExportProducesNoDiffWhenUnchanged ist der Test zu einem Mangel, den erst
// der Praxislauf gezeigt hat: Die Übersicht trug einen Zeitstempel im Kopf und
// änderte sich damit bei JEDEM Export — auch wenn inhaltlich nichts passiert war.
// Für eine Git-Sicherung ist das der entscheidende Punkt: Ein Lauf ohne
// Änderungen darf keinen Diff erzeugen.
func TestExportProducesNoDiffWhenUnchanged(t *testing.T) {
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "eintrag", Title: "Eintrag",
Content: "Inhalt", Tags: []string{"ops"}, Source: store.SourceHuman})
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
// Inhalt aller Dateien merken.
dir := filepath.Join(target, "plattform")
before := make(map[string]string)
files, _ := os.ReadDir(dir)
for _, d := range files {
before[d.Name()] = readFile(t, dir, d.Name())
}
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
for name, old := range before {
if newer := readFile(t, dir, name); newer != old {
t.Errorf("%s hat sich ohne inhaltliche Änderung verändert:\n--- vorher ---\n%s\n--- nachher ---\n%s",
name, old, newer)
}
}
}
// TestExportIndex prüft die Übersicht.
func TestExportIndex(t *testing.T) {
e, db, project, target := setup(t)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "a", Title: "Alpha",
Content: "x", Tags: []string{"ops"}, Source: store.SourceHuman, Curated: true})
db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "b", Title: "Beta",
Content: "x", Source: store.SourceAgent})
if _, err := e.ExportProject(ctx, db, project); err != nil {
t.Fatal(err)
}
index := readFile(t, target, "plattform", "_index.md")
// Kein Zeitstempel im Kopf — siehe TestExportProducesNoDiffWhenUnchanged.
if strings.Contains(index, "generated_at") {
t.Error("die Übersicht trägt einen Zeitstempel und erzeugt damit bei jedem Lauf einen Diff")
}
for _, want := range []string{
"# Plattform-Team",
"Wissen des Teams",
"## ops",
"## Ohne Schlagwort",
"[Alpha](a.md)",
"[Beta](b.md)",
} {
if !strings.Contains(index, want) {
t.Errorf("%q fehlt in der Übersicht:\n%s", want, index)
}
}
}
// TestExportAllProjects prüft, dass jedes Projekt seinen eigenen Ordner bekommt —
// die Abschottung gilt auch im Export.
func TestExportAllProjects(t *testing.T) {
e, db, projectA, target := setup(t)
ctx := context.Background()
// Beide Projekte liegen in derselben Workspace-Datei — dieselbe
// workspace_id wie projectA reicht, eine eigene Workspace ist nicht nötig.
projectB, err := db.CreateProject(ctx, projectA.WorkspaceID, "anderes", "Anderes Team", "")
if err != nil {
t.Fatal(err)
}
db.PutEntry(ctx, store.EntryWrite{ProjectID: projectA.ID, Slug: "a-wissen", Title: "A",
Content: "gehört zu A", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projectB.ID, Slug: "b-wissen", Title: "B",
Content: "gehört zu B", Source: store.SourceHuman})
ws := store.Workspace{ID: projectA.WorkspaceID, Name: "Intern"}
if _, err := e.ExportWorkspace(ctx, db, ws); err != nil {
t.Fatal(err)
}
if content := readFile(t, target, "plattform", "a-wissen.md"); !strings.Contains(content, "gehört zu A") {
t.Error("Projekt A wurde nicht korrekt exportiert")
}
if content := readFile(t, target, "anderes", "b-wissen.md"); !strings.Contains(content, "gehört zu B") {
t.Error("Projekt B wurde nicht korrekt exportiert")
}
// Kein Eintrag darf im Ordner des anderen Projekts liegen.
if _, err := os.Stat(filepath.Join(target, "plattform", "b-wissen.md")); err == nil {
t.Error("ein Eintrag aus Projekt B landete im Ordner von Projekt A")
}
}
func TestExportEmptyProject(t *testing.T) {
e, db, project, target := setup(t)
stats, err := e.ExportProject(context.Background(), db, project)
if err != nil {
t.Fatalf("ein leeres Projekt führte zu einem Fehler: %v", err)
}
// Nur die Übersicht.
if stats.Written != 1 {
t.Errorf("%d Dateien, erwartet 1", stats.Written)
}
if _, err := os.Stat(filepath.Join(target, "plattform", "_index.md")); err != nil {
t.Error("auch bei einem leeren Projekt sollte eine Übersicht entstehen")
}
}
// TestExportSeparatesWorkspaces: Zwei Workspaces mit einem Projekt gleichen
// Bezeichners dürfen sich im Export weder überschreiben noch gegenseitig
// Dateien wegräumen. Vor der Ebene je Workspace passierte genau das.
func TestExportSeparatesWorkspaces(t *testing.T) {
ctx := context.Background()
root := t.TempDir()
e := New(root)
// Zwei getrennte Workspace-Dateien, wie in echt.
type workspace struct {
ws store.Workspace
db *store.Store
}
var both []workspace
for _, name := range []string{"Vertrieb", "Technik"} {
db, err := store.Open(filepath.Join(t.TempDir(), name+".db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
ws := store.Workspace{ID: store.NewID(), Name: name}
project, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "")
if err != nil {
t.Fatal(err)
}
if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: project.ID, Slug: "nur-" + strings.ToLower(name),
Title: name, Content: "gehört zu " + name, Source: store.SourceHuman}); err != nil {
t.Fatal(err)
}
both = append(both, workspace{ws, db})
}
// Zweimal hintereinander: Beim zweiten Lauf würde das Aufräumen die
// Dateien des anderen Workspace löschen, wenn sie im selben Ordner lägen.
for run := 0; run < 2; run++ {
for _, w := range both {
if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil {
t.Fatal(err)
}
}
}
for _, w := range both {
file := "nur-" + strings.ToLower(w.ws.Name) + ".md"
if content := readFile(t, root, w.ws.ID, "plattform", file); !strings.Contains(content, "gehört zu "+w.ws.Name) {
t.Errorf("Eintrag von %s fehlt oder ist falsch", w.ws.Name)
}
if content := readFile(t, root, w.ws.ID, workspaceFilename); !strings.Contains(content, w.ws.Name) {
t.Errorf("_workspace.md nennt %s nicht", w.ws.Name)
}
}
}