package export import ( "context" "os" "path/filepath" "strings" "testing" "munin/internal/store" ) // aufbau legt Datenbank, Projekt und Exporter an. func aufbau(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() projekt, err := db.CreateProject(ctx, workspaceID, "plattform", "Plattform-Team", "Wissen des Teams") if err != nil { t.Fatal(err) } // Der Exporter schreibt unter //. 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, projekt, filepath.Join(root, workspaceID) } // lies gibt den Inhalt einer exportierten Datei zurück. func lies(t *testing.T, teile ...string) string { t.Helper() inhalt, err := os.ReadFile(filepath.Join(teile...)) if err != nil { t.Fatalf("Datei nicht lesbar: %v", err) } return string(inhalt) } func TestExportWritesEntries(t *testing.T) { e, db, projekt, ziel := aufbau(t) ctx := context.Background() if _, err := db.PutEntry(ctx, store.EntryWrite{ ProjectID: projekt.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, projekt) 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) } inhalt := lies(t, ziel, "plattform", "deployment.md") // Der Kopf muss alle Felder tragen, damit sich der Bestand daraus auch // wieder einlesen ließe. for _, erwartet := 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(inhalt, erwartet) { t.Errorf("%q fehlt im Dateikopf:\n%s", erwartet, inhalt) } } if !strings.Contains(inhalt, "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, projekt, ziel := aufbau(t) ctx := context.Background() heikel := `Fehler: "Zugriff verweigert" — siehe #42` if _, err := db.PutEntry(ctx, store.EntryWrite{ ProjectID: projekt.ID, Slug: "heikel", Title: heikel, Content: "Text", Source: store.SourceHuman, }); err != nil { t.Fatal(err) } if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } inhalt := lies(t, ziel, "plattform", "heikel.md") // Der Wert muss in Anführungszeichen stehen und die enthaltenen maskiert sein. if !strings.Contains(inhalt, `title: "Fehler: \"Zugriff verweigert\" — siehe #42"`) { t.Errorf("der Titel wurde nicht sauber maskiert:\n%s", inhalt) } // Der Kopf muss weiterhin genau zwei Trennlinien haben. if anzahl := strings.Count(inhalt, "\n---\n"); anzahl != 1 { kopfEnde := strings.Index(inhalt[4:], "---") if kopfEnde == -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, projekt, ziel := aufbau(t) ctx := context.Background() db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "bleibt", Title: "Bleibt", Content: "x", Source: store.SourceHuman}) db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "geht", Title: "Geht", Content: "x", Source: store.SourceHuman}) if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(ziel, "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, projekt.ID, "geht", "test"); err != nil { t.Fatal(err) } stats, err := e.ExportProject(ctx, db, projekt) 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(ziel, "plattform", "geht.md")); err == nil { t.Error("die Datei des gelöschten Eintrags liegt noch da") } if _, err := os.Stat(filepath.Join(ziel, "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, projekt, ziel := aufbau(t) ctx := context.Background() ordner := filepath.Join(ziel, "plattform") if err := os.MkdirAll(ordner, 0750); err != nil { t.Fatal(err) } // Eine Nicht-Markdown-Datei und ein Unterordner müssen überleben. os.WriteFile(filepath.Join(ordner, "notizen.txt"), []byte("privat"), 0640) os.MkdirAll(filepath.Join(ordner, "anhaenge"), 0750) if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(ordner, "notizen.txt")); err != nil { t.Error("eine fremde Datei wurde entfernt") } if _, err := os.Stat(filepath.Join(ordner, "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, projekt, ziel := aufbau(t) ctx := context.Background() db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", Content: "Inhalt", Source: store.SourceHuman}) if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } pfad := filepath.Join(ziel, "plattform", "eintrag.md") vorher, err := os.Stat(pfad) if err != nil { t.Fatal(err) } if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } nachher, err := os.Stat(pfad) if err != nil { t.Fatal(err) } if !vorher.ModTime().Equal(nachher.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, projekt, ziel := aufbau(t) ctx := context.Background() db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "eintrag", Title: "Eintrag", Content: "Inhalt", Tags: []string{"ops"}, Source: store.SourceHuman}) if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } // Inhalt aller Dateien merken. ordner := filepath.Join(ziel, "plattform") vorher := make(map[string]string) dateien, _ := os.ReadDir(ordner) for _, d := range dateien { vorher[d.Name()] = lies(t, ordner, d.Name()) } if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } for name, alt := range vorher { if neu := lies(t, ordner, name); neu != alt { t.Errorf("%s hat sich ohne inhaltliche Änderung verändert:\n--- vorher ---\n%s\n--- nachher ---\n%s", name, alt, neu) } } } // TestExportIndex prüft die Übersicht. func TestExportIndex(t *testing.T) { e, db, projekt, ziel := aufbau(t) ctx := context.Background() db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "Alpha", Content: "x", Tags: []string{"ops"}, Source: store.SourceHuman, Curated: true}) db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "Beta", Content: "x", Source: store.SourceAgent}) if _, err := e.ExportProject(ctx, db, projekt); err != nil { t.Fatal(err) } index := lies(t, ziel, "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 _, erwartet := range []string{ "# Plattform-Team", "Wissen des Teams", "## ops", "## Ohne Schlagwort", "[Alpha](a.md)", "[Beta](b.md)", } { if !strings.Contains(index, erwartet) { t.Errorf("%q fehlt in der Übersicht:\n%s", erwartet, index) } } } // TestExportAllProjects prüft, dass jedes Projekt seinen eigenen Ordner bekommt — // die Abschottung gilt auch im Export. func TestExportAllProjects(t *testing.T) { e, db, projektA, ziel := aufbau(t) ctx := context.Background() // Beide Projekte liegen in derselben Workspace-Datei — dieselbe // workspace_id wie projektA reicht, eine eigene Workspace ist nicht nötig. projektB, err := db.CreateProject(ctx, projektA.WorkspaceID, "anderes", "Anderes Team", "") if err != nil { t.Fatal(err) } db.PutEntry(ctx, store.EntryWrite{ProjectID: projektA.ID, Slug: "a-wissen", Title: "A", Content: "gehört zu A", Source: store.SourceHuman}) db.PutEntry(ctx, store.EntryWrite{ProjectID: projektB.ID, Slug: "b-wissen", Title: "B", Content: "gehört zu B", Source: store.SourceHuman}) ws := store.Workspace{ID: projektA.WorkspaceID, Name: "Intern"} if _, err := e.ExportWorkspace(ctx, db, ws); err != nil { t.Fatal(err) } if inhalt := lies(t, ziel, "plattform", "a-wissen.md"); !strings.Contains(inhalt, "gehört zu A") { t.Error("Projekt A wurde nicht korrekt exportiert") } if inhalt := lies(t, ziel, "anderes", "b-wissen.md"); !strings.Contains(inhalt, "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(ziel, "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, projekt, ziel := aufbau(t) stats, err := e.ExportProject(context.Background(), db, projekt) 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(ziel, "plattform", "_index.md")); err != nil { t.Error("auch bei einem leeren Projekt sollte eine Übersicht entstehen") } } // TestExportTrenntWorkspaces: 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 TestExportTrenntWorkspaces(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 beide []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} projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform", "") if err != nil { t.Fatal(err) } if _, err := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "nur-" + strings.ToLower(name), Title: name, Content: "gehört zu " + name, Source: store.SourceHuman}); err != nil { t.Fatal(err) } beide = append(beide, 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 lauf := 0; lauf < 2; lauf++ { for _, w := range beide { if _, err := e.ExportWorkspace(ctx, w.db, w.ws); err != nil { t.Fatal(err) } } } for _, w := range beide { datei := "nur-" + strings.ToLower(w.ws.Name) + ".md" if inhalt := lies(t, root, w.ws.ID, "plattform", datei); !strings.Contains(inhalt, "gehört zu "+w.ws.Name) { t.Errorf("Eintrag von %s fehlt oder ist falsch", w.ws.Name) } if inhalt := lies(t, root, w.ws.ID, workspaceDateiname); !strings.Contains(inhalt, w.ws.Name) { t.Errorf("_workspace.md nennt %s nicht", w.ws.Name) } } }