munin/internal/store/papierkorb_test.go

343 lines
12 KiB
Go

package store
import (
"context"
"errors"
"testing"
"time"
)
// TestPapierkorbUnsichtbar ist der wichtigste Test des Papierkorbs: Jeder
// lesende Weg muss einen gelöschten Eintrag ausblenden. Kommt später eine
// neue Abfrage auf entries dazu, gehört sie hier mit hinein (siehe
// nichtImPapierkorb in entries.go).
//
// Aufbau: "lebt" verweist auf "weg", "weg" verweist auf "lebt". Beide tragen
// das Schlagwort "gemeinsam", "weg" zusätzlich "nur-weg". Dann wird "weg"
// gelöscht.
func TestPapierkorbUnsichtbar(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
lebt, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt",
Content: "siehe [[weg]]", Tags: []string{"gemeinsam"}, Source: SourceHuman})
weg, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg",
Content: "siehe [[lebt]]", Tags: []string{"gemeinsam", "nur-weg"}, Source: SourceHuman, Curated: true})
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "agent-1"); err != nil {
t.Fatal(err)
}
// t.Run fasst jeden Weg als eigenen Untertest — schlägt einer fehl, steht
// sein Name in der Ausgabe.
t.Run("EntryBySlug", func(t *testing.T) {
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
})
t.Run("ListEntries", func(t *testing.T) {
for name, filter := range map[string]EntryFilter{
"ohne Filter": {},
"mit Schlagwort": {Tag: "nur-weg"},
"nur kuratierte": {Curated: true},
} {
liste, err := s.ListEntries(ctx, p.ID, filter)
if err != nil {
t.Fatal(err)
}
for _, e := range liste {
if e.Slug == "weg" {
t.Errorf("%s: gelöschter Eintrag in der Liste", name)
}
}
}
})
t.Run("AllEntries (Suchindex, Export)", func(t *testing.T) {
alle, _ := s.AllEntries(ctx, p.ID)
if len(alle) != 1 || alle[0].Slug != "lebt" {
t.Errorf("AllEntries = %v, erwartet nur „lebt“", alle)
}
})
t.Run("CountEntries", func(t *testing.T) {
if n, _ := s.CountEntries(ctx, p.ID); n != 1 {
t.Errorf("%d Einträge gezählt, erwartet 1", n)
}
})
t.Run("ListTags", func(t *testing.T) {
tags, _ := s.ListTags(ctx, p.ID)
for _, tc := range tags {
if tc.Tag == "nur-weg" {
t.Error("Schlagwort, das nur ein gelöschter Eintrag trägt, wird noch angezeigt")
}
if tc.Tag == "gemeinsam" && tc.Count != 1 {
t.Errorf("„gemeinsam“ zählt %d, erwartet 1", tc.Count)
}
}
})
t.Run("Backlinks", func(t *testing.T) {
// "weg" verweist auf "lebt" — dieser Rückverweis muss verschwinden.
links, _ := s.Backlinks(ctx, p.ID, "lebt")
if len(links) != 0 {
t.Errorf("Rückverweise = %v, erwartet keine", links)
}
})
t.Run("LinkGraph", func(t *testing.T) {
knoten, kanten, _ := s.LinkGraph(ctx, p.ID)
for _, k := range knoten {
// "lebt" verweist weiter auf "weg" — der Knoten darf bleiben, aber
// nur als gestrichelter "gibt es nicht".
if k.Slug == "weg" && k.Exists {
t.Error("gelöschter Eintrag erscheint im Graph als vorhanden")
}
}
for _, e := range kanten {
if e.From == "weg" {
t.Error("Verweis eines gelöschten Eintrags erscheint im Graph")
}
}
})
t.Run("BulkUpdateTags", func(t *testing.T) {
geaendert, _ := s.BulkUpdateTags(ctx, p.ID, []string{lebt.ID, weg.ID}, []string{"neu"}, nil)
if len(geaendert) != 1 || geaendert[0].Slug != "lebt" {
t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", geaendert)
}
})
}
func TestListTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
for _, slug := range []string{"a", "b", "c"} {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Source: SourceHuman})
}
s.MoveEntryToTrash(ctx, p.ID, "a", "agent-1")
s.MoveEntryToTrash(ctx, p.ID, "b", "tom")
alle, _ := s.ListTrash(ctx, p.ID, "")
if len(alle) != 2 {
t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(alle))
}
vomAgenten, _ := s.ListTrash(ctx, p.ID, "agent-1")
if len(vomAgenten) != 1 || vomAgenten[0].Slug != "a" || vomAgenten[0].DeletedBy != "agent-1" {
t.Errorf("Filter nach Zugang: %+v", vomAgenten)
}
}
func TestRestoreFromTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
fremd, _ := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "Inhalt",
Tags: []string{"x"}, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: fremd.ID, Slug: "f", Title: "F", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "tom")
s.MoveEntryToTrash(ctx, fremd.ID, "f", "tom")
// "f" liegt im Papierkorb eines ANDEREN Projekts — darf nicht mit.
zurueck, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"})
if err != nil {
t.Fatal(err)
}
if len(zurueck) != 1 || zurueck[0].Slug != "e" {
t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", zurueck)
}
if len(zurueck[0].Tags) != 1 || zurueck[0].Tags[0] != "x" {
t.Errorf("Schlagworte nach Wiederherstellen = %v", zurueck[0].Tags)
}
e, err := s.EntryBySlug(ctx, p.ID, "e")
if err != nil || e.Content != "Inhalt" || e.Version != 1 {
t.Errorf("nach Wiederherstellen: %+v, %v — Inhalt und Fassung müssen unverändert sein", e, err)
}
if liste, _ := s.ListTrash(ctx, fremd.ID, ""); len(liste) != 1 {
t.Error("der Eintrag des fremden Projekts wurde angefasst")
}
}
// TestPutEntryRevivesFromTrash: Wer einen Bezeichner aus dem Papierkorb neu
// beschreibt, holt den Eintrag zurück und überschreibt ihn. Der gelöschte
// Stand bleibt im Verlauf.
func TestPutEntryRevivesFromTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Alt", Content: "gelöscht", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1")
neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent})
if err != nil {
t.Fatal(err)
}
if !neu.RestoredFromTrash {
t.Error("RestoredFromTrash nicht gesetzt")
}
if neu.Version != 2 {
t.Errorf("Fassung %d, erwartet 2", neu.Version)
}
if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil {
t.Errorf("Eintrag nach dem Überschreiben nicht sichtbar: %v", err)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 0 {
t.Error("Eintrag liegt nach dem Überschreiben noch im Papierkorb")
}
alt, err := s.EntryVersionByNumber(ctx, neu.ID, 1)
if err != nil || alt.Content != "gelöscht" {
t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, alt.Content)
}
// Ein normaler Schreibvorgang danach meldet nichts mehr vom Papierkorb.
weiter, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent})
if weiter.RestoredFromTrash {
t.Error("RestoredFromTrash bei einem gewöhnlichen Update gesetzt")
}
}
// TestPutEntryConflictWhenDeletedMeanwhile: Wer einen Stand gelesen hat und
// ihn fortschreiben will, während jemand anderes gelöscht hat, bekommt einen
// Konflikt — die Löschung wird nicht unbemerkt rückgängig gemacht.
func TestPutEntryConflictWhenDeletedMeanwhile(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "1", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "e", "agent-1")
_, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "2",
Source: SourceHuman, ExpectedVersion: 1})
if !errors.Is(err, ErrConflict) {
t.Errorf("Fehler = %v, erwartet ErrConflict", err)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 {
t.Error("der Eintrag wurde trotz Konflikt aus dem Papierkorb geholt")
}
}
// TestPurgeFromTrashOnlyTrashed: Endgültig löschen fasst nie einen Eintrag an,
// der nicht im Papierkorb liegt.
func TestPurgeFromTrashOnlyTrashed(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "T", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "weg", "tom")
n, err := s.PurgeFromTrash(ctx, p.ID, []string{"lebt", "weg"})
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("%d endgültig gelöscht, erwartet 1", n)
}
if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil {
t.Error("ein Eintrag außerhalb des Papierkorbs wurde endgültig gelöscht")
}
// Der Bezeichner ist danach wieder frei: Neu anlegen beginnt bei Fassung 1.
neu, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
if err != nil || neu.Version != 1 || neu.RestoredFromTrash {
t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", neu, err)
}
}
// TestPurgeTrashAfterRetention: Die tägliche Aufgabe entfernt nur, was länger
// als die Frist im Papierkorb liegt.
func TestPurgeTrashAfterRetention(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
for _, slug := range []string{"alt", "frisch", "lebt"} {
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: slug, Title: slug, Source: SourceHuman})
}
s.MoveEntryToTrash(ctx, p.ID, "alt", "tom")
s.MoveEntryToTrash(ctx, p.ID, "frisch", "tom")
// "alt" künstlich vor 31 Tage zurückdatieren — die Tests sollen nicht
// einen Monat warten.
vorEinemMonat := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, vorEinemMonat); err != nil {
t.Fatal(err)
}
grenze := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano)
entfernt, err := s.PurgeTrash(ctx, grenze)
if err != nil {
t.Fatal(err)
}
if len(entfernt[p.ID]) != 1 || entfernt[p.ID][0] != "alt" {
t.Errorf("entfernt: %v, erwartet nur „alt“", entfernt)
}
if liste, _ := s.ListTrash(ctx, p.ID, ""); len(liste) != 1 || liste[0].Slug != "frisch" {
t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", liste)
}
if _, err := s.EntryBySlug(ctx, p.ID, "lebt"); err != nil {
t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt")
}
}
// TestMoveEntriesToTrash prüft das Sammellöschen: Die gültigen Einträge
// landen im Papierkorb, IDs aus fremden Projekten und schon gelöschte werden
// übersprungen, Unbeteiligte bleiben unberührt.
func TestMoveEntriesToTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
fremdesProjekt, err := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
if err != nil {
t.Fatal(err)
}
a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Content: "x", Source: SourceHuman})
b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "x", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "bleibt", Title: "Bleibt", Content: "x", Source: SourceHuman})
fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: fremdesProjekt.ID, Slug: "f", Title: "F", Content: "x", Source: SourceHuman})
geloescht, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID, fremd.ID, "gibtesnicht"}, "mensch")
if err != nil {
t.Fatal(err)
}
if len(geloescht) != 2 || geloescht[0] != "a" || geloescht[1] != "b" {
t.Errorf("gelöscht = %v, erwartet [a b]", geloescht)
}
if _, err := s.EntryBySlug(ctx, fremdesProjekt.ID, "f"); err != nil {
t.Errorf("der Eintrag eines fremden Projekts wurde angetastet: %v", err)
}
if _, err := s.EntryBySlug(ctx, p.ID, "bleibt"); err != nil {
t.Errorf("ein nicht ausgewählter Eintrag ist weg: %v", err)
}
papierkorb, err := s.ListTrash(ctx, p.ID, "")
if err != nil {
t.Fatal(err)
}
if len(papierkorb) != 2 || papierkorb[0].DeletedBy != "mensch" {
t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", papierkorb)
}
// Doppelt abgeschickt: nichts mehr zu tun, kein Fehler.
nochmal, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch")
if err != nil || len(nochmal) != 0 {
t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", nochmal, err)
}
}