344 lines
10 KiB
Go
344 lines
10 KiB
Go
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()
|
|
ws, err := db.EnsureWorkspace(ctx, "Test")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projekt, err := db.CreateProject(ctx, ws.ID, "plattform", "Plattform-Team", "Wissen des Teams")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
ziel := t.TempDir()
|
|
return New(db, ziel), db, projekt, ziel
|
|
}
|
|
|
|
// 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, 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, 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, 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")
|
|
}
|
|
|
|
if err := db.DeleteEntry(ctx, projekt.ID, "geht"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
stats, err := e.ExportProject(ctx, 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, _, 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, 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, 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, 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, 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, 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, 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()
|
|
|
|
ws, _ := db.EnsureWorkspace(ctx, "Test")
|
|
projektB, err := db.CreateProject(ctx, ws.ID, "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})
|
|
|
|
if _, err := e.ExportAll(ctx, ws.ID); 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, _, projekt, ziel := aufbau(t)
|
|
|
|
stats, err := e.ExportProject(context.Background(), 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")
|
|
}
|
|
}
|