343 lines
12 KiB
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)
|
|
}
|
|
}
|