munin/internal/importer/importer_test.go
2026-10-19 10:13:56 +02:00

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, projectSlug 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(), projectSlug, "Projekt "+projectSlug, "")
if err != nil {
t.Fatal(err)
}
return s, p
}
// pack baut ein ZIP-Archiv aus Pfad → Inhalt.
func pack(t *testing.T, files map[string]string) []byte {
t.Helper()
var buf bytes.Buffer
w := zip.NewWriter(&buf)
for filePath, content := range files {
f, err := w.Create(filePath)
if err != nil {
t.Fatal(err)
}
f.Write([]byte(content))
}
if err := w.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// packDir packt alle Dateien eines Ordners, so wie ein Mensch den
// Exportordner eines Projekts zippen würde.
func packDir(t *testing.T, dir string) []byte {
t.Helper()
files := map[string]string{}
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
content, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
t.Fatal(err)
}
files["projekt/"+e.Name()] = string(content)
}
return pack(t, files)
}
// 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()
source, qp := testStore(t, "quelle")
old := 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: old, UpdatedAt: old},
{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 := source.PutEntry(ctx, w); err != nil {
t.Fatal(err)
}
}
exportDir := t.TempDir()
qp.WorkspaceID = "ws-test"
if _, err := export.New(exportDir).ExportProject(ctx, source, qp); err != nil {
t.Fatal(err)
}
files, err := ReadZip(packDir(t, filepath.Join(exportDir, "ws-test", "quelle")))
if err != nil {
t.Fatal(err)
}
if len(files) != 2 {
t.Fatalf("%d Dateien gelesen, erwartet 2 (_index.md gehört nicht dazu)", len(files))
}
target, zp := testStore(t, "ziel")
report, err := Import(ctx, target, zp.ID, files, Options{Mode: ModeSkip, Author: "importeur"})
if err != nil {
t.Fatal(err)
}
if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows)
}
for _, w := range original {
a, _ := source.EntryBySlug(ctx, qp.ID, w.Slug)
b, err := target.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.
back, _ := target.Backlinks(ctx, zp.ID, "rollback")
if len(back) != 1 || back[0].Slug != "deployment" {
t.Errorf("Rückverweise nach dem Import: %+v", back)
}
// Ein zweiter Lauf ändert nichts und erzeugt keine neuen Fassungen.
again, err := Import(ctx, target, zp.ID, files, Options{Mode: ModeOverwrite, Author: "importeur"})
if err != nil {
t.Fatal(err)
}
if again.Unchanged != 2 {
t.Errorf("zweiter Lauf: %s, erwartet 2 unverändert", again.Summary())
}
}
// TestRoundTripFromDownload: Das ZIP aus dem Download (export.WriteZIP)
// muss ohne Umweg wieder in den Import passen — genau dafür ist es da.
func TestRoundTripFromDownload(t *testing.T) {
ctx := context.Background()
source, 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 := source.PutEntry(ctx, w); err != nil {
t.Fatal(err)
}
}
files, err := export.ProjectFiles(ctx, source, qp)
if err != nil {
t.Fatal(err)
}
var zip bytes.Buffer
if err := export.WriteZIP(&zip, files); err != nil {
t.Fatal(err)
}
read, err := ReadZip(zip.Bytes())
if err != nil {
t.Fatal(err)
}
target, zp := testStore(t, "ziel")
report, err := Import(ctx, target, zp.ID, read, Options{Mode: ModeSkip, Author: "importeur"})
if err != nil {
t.Fatal(err)
}
if report.Created != 2 || report.Failed != 0 {
t.Fatalf("Bericht: %s — %+v", report.Summary(), report.Rows)
}
for _, slug := range []string{"deployment", "rollback"} {
a, _ := source.EntryBySlug(ctx, qp.ID, slug)
b, err := target.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)
}
files := []File{
{Path: "vorhanden.md", Content: []byte("# Vorhanden\n\nneu")},
{Path: "frisch.md", Content: []byte("# Frisch\n\nText")},
}
// Preview schreibt nichts.
preview, err := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Preview: true, Author: "imp"})
if err != nil {
t.Fatal(err)
}
if preview.Created != 1 || preview.Overwritten != 1 || len(preview.Written) != 0 {
t.Errorf("Vorschau: %s", preview.Summary())
}
if _, err := s.EntryBySlug(ctx, p.ID, "frisch"); err == nil {
t.Error("die Vorschau hat geschrieben")
}
// Überspringen lässt Vorhandenes in Ruhe.
over, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip, Author: "imp"})
if over.Created != 1 || over.Skipped != 1 {
t.Errorf("Überspringen: %s", over.Summary())
}
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.
newer, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeOverwrite, Author: "imp"})
if newer.Overwritten != 1 || newer.Unchanged != 1 {
t.Errorf("Überschreiben: %s", newer.Summary())
}
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)
}
files := []File{{Path: "weg.md", Content: []byte("# Weg\n\nwieder da")}}
b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip})
if b.Skipped != 1 || !strings.Contains(b.Rows[0].Reason, "Papierkorb") {
t.Errorf("Bericht: %s, Grund %q", b.Summary(), b.Rows[0].Reason)
}
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")
files := []File{
{Path: "ops/deploy.md", Content: []byte("eins")},
{Path: "alt/deploy.md", Content: []byte("zwei")},
}
b, _ := Import(ctx, s, p.ID, files, Options{Mode: ModeSkip})
if b.Created != 1 || b.Failed != 1 || !strings.Contains(b.Rows[1].Reason, "ops/deploy.md") {
t.Errorf("Bericht: %s — %+v", b.Summary(), b.Rows)
}
}
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)
}
noHeading, err := Parse("notiz.md", []byte("Nur Text"))
if err != nil {
t.Fatal(err)
}
if noHeading.Title != "notiz" {
t.Errorf("Titel ohne Überschrift: %q", noHeading.Title)
}
}
func TestParseForeignFrontmatter(t *testing.T) {
file := `---
title: 'It''s a title'
date: 2024-01-01
tags:
- Ops
- "release notes"
draft: false
---
Inhalt`
e, err := Parse("post.md", []byte(file))
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) {
cases := 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, content := range cases {
if _, err := Parse("x.md", []byte(content)); err == nil {
t.Errorf("%s: kein Fehler", name)
}
}
}
func TestReadZipSkipsAndLimits(t *testing.T) {
archive := pack(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",
})
files, err := ReadZip(archive)
if err != nil {
t.Fatal(err)
}
if len(files) != 1 || files[0].Path != "projekt/eintrag.md" {
t.Errorf("gelesen: %+v", files)
}
tooLarge := pack(t, map[string]string{"gross.md": strings.Repeat("x", MaxFileBytes+1)})
if _, err := ReadZip(tooLarge); err == nil {
t.Error("eine zu große Datei wurde angenommen")
}
if _, err := ReadZip([]byte("kein zip")); err == nil {
t.Error("Unsinn wurde als Archiv angenommen")
}
if _, err := ReadZip(pack(t, map[string]string{"bild.png": "x"})); err == nil {
t.Error("ein Archiv ohne Markdown wurde angenommen")
}
}