369 lines
11 KiB
Go
369 lines
11 KiB
Go
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())
|
|
}
|
|
}
|
|
|
|
// TestRoundTripFromDownload: Das ZIP aus dem Download (export.SchreibeZIP)
|
|
// muss ohne Umweg wieder in den Import passen — genau dafür ist es da.
|
|
func TestRoundTripFromDownload(t *testing.T) {
|
|
ctx := context.Background()
|
|
quelle, qp := testStore(t, "quelle")
|
|
|
|
for _, w := range []store.EntryWrite{
|
|
{Slug: "deployment", Title: "Deployment", Content: "siehe [[rollback]]", Tags: []string{"ops"}, Source: store.SourceAgent, Author: "agent-1"},
|
|
{Slug: "rollback", Title: "Rollback", Content: "Zurück.", Source: store.SourceHuman, Author: "anna"},
|
|
} {
|
|
w.ProjectID = qp.ID
|
|
if _, err := quelle.PutEntry(ctx, w); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
dateien, err := export.ProjektDateien(ctx, quelle, qp)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var zip bytes.Buffer
|
|
if err := export.SchreibeZIP(&zip, dateien); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
gelesen, err := LeseZip(zip.Bytes())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ziel, zp := testStore(t, "ziel")
|
|
bericht, err := Importiere(ctx, ziel, zp.ID, gelesen, 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 _, slug := range []string{"deployment", "rollback"} {
|
|
a, _ := quelle.EntryBySlug(ctx, qp.ID, slug)
|
|
b, err := ziel.EntryBySlug(ctx, zp.ID, slug)
|
|
if err != nil {
|
|
t.Fatalf("%s fehlt nach dem Import: %v", slug, err)
|
|
}
|
|
if a.Title != b.Title || a.Content != b.Content || !slices.Equal(a.Tags, b.Tags) || a.Author != b.Author {
|
|
t.Errorf("%s nach dem Import verändert:\nvorher %+v\nnachher %+v", slug, a, b)
|
|
}
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|