package importer import ( "archive/zip" "bytes" "context" "os" "path/filepath" "slices" "strings" "testing" "time" "munin/internal/export" "munin/internal/store" ) // testStore öffnet eine frische Datenbank mit einem Projekt. func testStore(t *testing.T, projektSlug string) (*store.Store, store.Project) { t.Helper() s, err := store.Open(filepath.Join(t.TempDir(), "test.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { s.Close() }) p, err := s.CreateProject(context.Background(), store.NewID(), projektSlug, "Projekt "+projektSlug, "") if err != nil { t.Fatal(err) } return s, p } // packe baut ein ZIP-Archiv aus Pfad → Inhalt. func packe(t *testing.T, dateien map[string]string) []byte { t.Helper() var puffer bytes.Buffer w := zip.NewWriter(&puffer) for pfad, inhalt := range dateien { f, err := w.Create(pfad) if err != nil { t.Fatal(err) } f.Write([]byte(inhalt)) } if err := w.Close(); err != nil { t.Fatal(err) } return puffer.Bytes() } // packeOrdner packt alle Dateien eines Ordners, so wie ein Mensch den // Exportordner eines Projekts zippen würde. func packeOrdner(t *testing.T, ordner string) []byte { t.Helper() dateien := map[string]string{} eintraege, err := os.ReadDir(ordner) if err != nil { t.Fatal(err) } for _, e := range eintraege { inhalt, err := os.ReadFile(filepath.Join(ordner, e.Name())) if err != nil { t.Fatal(err) } dateien["projekt/"+e.Name()] = string(inhalt) } return packe(t, dateien) } // TestRoundTripFromExport ist der wichtigste Test: Was der Export schreibt, // kommt beim Import unverändert wieder heraus, samt Schlagworten, Herkunft, // Autor, Prüfvermerk und Änderungsdatum. func TestRoundTripFromExport(t *testing.T) { ctx := context.Background() quelle, qp := testStore(t, "quelle") alt := time.Date(2023, 5, 1, 8, 30, 0, 0, time.UTC) original := []store.EntryWrite{ {Slug: "deployment", Title: "Deployment: so geht's", Content: "Schritt 1\n\n# Keine Titelzeile\n\nsiehe [[rollback]]", Tags: []string{"ops", "release"}, Source: store.SourceAgent, Author: "agent-1", Curated: true, CreatedAt: alt, UpdatedAt: alt}, {Slug: "rollback", Title: `Rollback mit "Anführungszeichen"`, Content: "Zurück.\nZweite Zeile", Source: store.SourceHuman, Author: "anna"}, } for _, w := range original { w.ProjectID = qp.ID if _, err := quelle.PutEntry(ctx, w); err != nil { t.Fatal(err) } } exportOrdner := t.TempDir() qp.WorkspaceID = "ws-test" if _, err := export.New(exportOrdner).ExportProject(ctx, quelle, qp); err != nil { t.Fatal(err) } dateien, err := LeseZip(packeOrdner(t, filepath.Join(exportOrdner, "ws-test", "quelle"))) if err != nil { t.Fatal(err) } if len(dateien) != 2 { t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(dateien)) } ziel, zp := testStore(t, "ziel") bericht, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "importeur"}) if err != nil { t.Fatal(err) } if bericht.Neu != 2 || bericht.Fehler != 0 { t.Fatalf("Bericht: %s — %+v", bericht.Zusammenfassung(), bericht.Zeilen) } for _, w := range original { a, _ := quelle.EntryBySlug(ctx, qp.ID, w.Slug) b, err := ziel.EntryBySlug(ctx, zp.ID, w.Slug) if err != nil { t.Fatalf("%s fehlt nach dem Import: %v", w.Slug, err) } if a.Title != b.Title || a.Content != b.Content || !slices.Equal(a.Tags, b.Tags) || a.Source != b.Source || a.Author != b.Author || a.Curated != b.Curated { t.Errorf("%s nach dem Import verändert:\nvorher %+v\nnachher %+v", w.Slug, a, b) } if !a.UpdatedAt.Truncate(time.Second).Equal(b.UpdatedAt) { t.Errorf("%s: Änderungsdatum %v, erwartet %v", w.Slug, b.UpdatedAt, a.UpdatedAt) } } // Die Verweise sind wieder da, rollback wird von deployment verlinkt. rueck, _ := ziel.Backlinks(ctx, zp.ID, "rollback") if len(rueck) != 1 || rueck[0].Slug != "deployment" { t.Errorf("Rückverweise nach dem Import: %+v", rueck) } // Ein zweiter Lauf ändert nichts und erzeugt keine neuen Fassungen. nochmal, err := Importiere(ctx, ziel, zp.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "importeur"}) if err != nil { t.Fatal(err) } if nochmal.Unveraendert != 2 { t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", nochmal.Zusammenfassung()) } } func TestConflictModes(t *testing.T) { ctx := context.Background() s, p := testStore(t, "test") if _, err := s.PutEntry(ctx, store.EntryWrite{ ProjectID: p.ID, Slug: "vorhanden", Title: "Vorhanden", Content: "alt", Source: store.SourceHuman, Author: "anna", }); err != nil { t.Fatal(err) } dateien := []Datei{ {Pfad: "vorhanden.md", Inhalt: []byte("# Vorhanden\n\nneu")}, {Pfad: "frisch.md", Inhalt: []byte("# Frisch\n\nText")}, } // Vorschau schreibt nichts. vorschau, err := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Vorschau: true, Autor: "imp"}) if err != nil { t.Fatal(err) } if vorschau.Neu != 1 || vorschau.Ueberschrieben != 1 || len(vorschau.Geschrieben) != 0 { t.Errorf("Vorschau: %s", vorschau.Zusammenfassung()) } if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil { t.Error("die Vorschau hat geschrieben") } // Überspringen lässt Vorhandenes in Ruhe. ueber, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen, Autor: "imp"}) if ueber.Neu != 1 || ueber.Uebersprungen != 1 { t.Errorf("Überspringen: %s", ueber.Zusammenfassung()) } if e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden"); e.Content != "alt" { t.Error("Überspringen hat überschrieben") } // Überschreiben legt eine neue Fassung an, die alte steht im Verlauf. neu, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberschreiben, Autor: "imp"}) if neu.Ueberschrieben != 1 || neu.Unveraendert != 1 { t.Errorf("Überschreiben: %s", neu.Zusammenfassung()) } e, _ := s.EntryBySlug(ctx, p.ID, "vorhanden") if e.Content != "neu" || e.Version != 2 || e.Author != "imp" { t.Errorf("nach dem Überschreiben: Inhalt %q, Fassung %d, Autor %q", e.Content, e.Version, e.Author) } } // TestTrashIsNotRevivedWhenSkipping prüft, dass ein Import im Modus // „Überspringen“ keinen gelöschten Eintrag zurückholt. func TestTrashIsNotRevivedWhenSkipping(t *testing.T) { ctx := context.Background() s, p := testStore(t, "test") s.PutEntry(ctx, store.EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Content: "x", Source: store.SourceHuman}) if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "anna"); err != nil { t.Fatal(err) } dateien := []Datei{{Pfad: "weg.md", Inhalt: []byte("# Weg\n\nwieder da")}} b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen}) if b.Uebersprungen != 1 || !strings.Contains(b.Zeilen[0].Grund, "Papierkorb") { t.Errorf("Bericht: %s, Grund %q", b.Zusammenfassung(), b.Zeilen[0].Grund) } if _, err := s.EntryBySlug(ctx, p.ID, "weg"); err == nil { t.Error("der gelöschte Eintrag wurde zurückgeholt") } } func TestDuplicateSlugInArchive(t *testing.T) { ctx := context.Background() s, p := testStore(t, "test") dateien := []Datei{ {Pfad: "ops/deploy.md", Inhalt: []byte("eins")}, {Pfad: "alt/deploy.md", Inhalt: []byte("zwei")}, } b, _ := Importiere(ctx, s, p.ID, dateien, Optionen{Modus: ModusUeberspringen}) if b.Neu != 1 || b.Fehler != 1 || !strings.Contains(b.Zeilen[1].Grund, "ops/deploy.md") { t.Errorf("Bericht: %s — %+v", b.Zusammenfassung(), b.Zeilen) } } func TestParsePlainMarkdown(t *testing.T) { e, err := Parse("Doku/Mein Leitfaden.md", []byte("\uFEFF# Leitfaden für Neue\r\n\r\nErster Absatz.\r\n")) if err != nil { t.Fatal(err) } if e.Slug != "mein-leitfaden" || e.Title != "Leitfaden für Neue" || e.Content != "Erster Absatz." { t.Errorf("gelesen: %+v", e) } ohneUeberschrift, err := Parse("notiz.md", []byte("Nur Text")) if err != nil { t.Fatal(err) } if ohneUeberschrift.Title != "notiz" { t.Errorf("Titel ohne Überschrift: %q", ohneUeberschrift.Title) } } func TestParseForeignFrontmatter(t *testing.T) { datei := `--- title: 'It''s a title' date: 2024-01-01 tags: - Ops - "release notes" draft: false --- Inhalt` e, err := Parse("post.md", []byte(datei)) if err != nil { t.Fatal(err) } if e.Title != "It's a title" || !slices.Equal(e.Tags, []string{"Ops", "release notes"}) || e.Content != "Inhalt" { t.Errorf("gelesen: %+v", e) } } func TestParseInlineListWithCommaInQuotes(t *testing.T) { e, err := Parse("x.md", []byte("---\ntags: [\"a, b\", c]\n---\nText")) if err != nil { t.Fatal(err) } if !slices.Equal(e.Tags, []string{"a, b", "c"}) { t.Errorf("Schlagworte: %q", e.Tags) } } func TestParseRejects(t *testing.T) { faelle := map[string]string{ "offener Kopf": "---\ntitle: x\nText ohne Ende", "leer": "---\ntitle: x\n---\n\n", "ungültiger slug": "---\nslug: \"Mit Leerzeichen\"\n---\nText", "nur eine Überschrift": "# Titel\n", } for name, inhalt := range faelle { if _, err := Parse("x.md", []byte(inhalt)); err == nil { t.Errorf("%s: kein Fehler", name) } } } func TestLeseZipSkipsAndLimits(t *testing.T) { archiv := packe(t, map[string]string{ "projekt/_index.md": "Übersicht", "projekt/eintrag.md": "Text", "projekt/bild.png": "PNG", "__MACOSX/projekt/._eintrag.md": "Müll", "projekt/.obsidian/workspace.md": "Einstellungen", }) dateien, err := LeseZip(archiv) if err != nil { t.Fatal(err) } if len(dateien) != 1 || dateien[0].Pfad != "projekt/eintrag.md" { t.Errorf("gelesen: %+v", dateien) } zuGross := packe(t, map[string]string{"gross.md": strings.Repeat("x", MaxDateiBytes+1)}) if _, err := LeseZip(zuGross); err == nil { t.Error("eine zu große Datei wurde angenommen") } if _, err := LeseZip([]byte("kein zip")); err == nil { t.Error("Unsinn wurde als Archiv angenommen") } if _, err := LeseZip(packe(t, map[string]string{"bild.png": "x"})); err == nil { t.Error("ein Archiv ohne Markdown wurde angenommen") } }