343 lines
12 KiB
Go
343 lines
12 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestTrashInvisible 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
|
|
// notInTrash 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 TestTrashInvisible(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
alive, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "lebt", Title: "Lebt",
|
|
Content: "siehe [[weg]]", Tags: []string{"gemeinsam"}, Source: SourceHuman})
|
|
gone, _ := 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},
|
|
} {
|
|
list, err := s.ListEntries(ctx, p.ID, filter)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, e := range list {
|
|
if e.Slug == "weg" {
|
|
t.Errorf("%s: gelöschter Eintrag in der Liste", name)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("AllEntries (Suchindex, Export)", func(t *testing.T) {
|
|
all, _ := s.AllEntries(ctx, p.ID)
|
|
if len(all) != 1 || all[0].Slug != "lebt" {
|
|
t.Errorf("AllEntries = %v, erwartet nur „lebt“", all)
|
|
}
|
|
})
|
|
|
|
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) {
|
|
nodes, edges, _ := s.LinkGraph(ctx, p.ID)
|
|
for _, k := range nodes {
|
|
// "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 edges {
|
|
if e.From == "weg" {
|
|
t.Error("Verweis eines gelöschten Eintrags erscheint im Graph")
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("BulkUpdateTags", func(t *testing.T) {
|
|
changed, _ := s.BulkUpdateTags(ctx, p.ID, []string{alive.ID, gone.ID}, []string{"neu"}, nil)
|
|
if len(changed) != 1 || changed[0].Slug != "lebt" {
|
|
t.Errorf("Sammelbearbeitung hat %v geändert, erwartet nur „lebt“", changed)
|
|
}
|
|
})
|
|
}
|
|
|
|
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")
|
|
|
|
all, _ := s.ListTrash(ctx, p.ID, "")
|
|
if len(all) != 2 {
|
|
t.Fatalf("%d Einträge im Papierkorb, erwartet 2", len(all))
|
|
}
|
|
|
|
fromAgent, _ := s.ListTrash(ctx, p.ID, "agent-1")
|
|
if len(fromAgent) != 1 || fromAgent[0].Slug != "a" || fromAgent[0].DeletedBy != "agent-1" {
|
|
t.Errorf("Filter nach Zugang: %+v", fromAgent)
|
|
}
|
|
}
|
|
|
|
func TestRestoreFromTrash(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
foreign, _ := 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: foreign.ID, Slug: "f", Title: "F", Source: SourceHuman})
|
|
s.MoveEntryToTrash(ctx, p.ID, "e", "tom")
|
|
s.MoveEntryToTrash(ctx, foreign.ID, "f", "tom")
|
|
|
|
// "f" liegt im Papierkorb eines ANDEREN Projekts — darf nicht mit.
|
|
back, err := s.RestoreFromTrash(ctx, p.ID, []string{"e", "f", "gibt-es-nicht"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(back) != 1 || back[0].Slug != "e" {
|
|
t.Fatalf("wiederhergestellt: %v, erwartet nur „e“", back)
|
|
}
|
|
if len(back[0].Tags) != 1 || back[0].Tags[0] != "x" {
|
|
t.Errorf("Schlagworte nach Wiederherstellen = %v", back[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 list, _ := s.ListTrash(ctx, foreign.ID, ""); len(list) != 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")
|
|
|
|
newer, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "frisch", Source: SourceAgent})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !newer.RestoredFromTrash {
|
|
t.Error("RestoredFromTrash nicht gesetzt")
|
|
}
|
|
if newer.Version != 2 {
|
|
t.Errorf("Fassung %d, erwartet 2", newer.Version)
|
|
}
|
|
|
|
if _, err := s.EntryBySlug(ctx, p.ID, "e"); err != nil {
|
|
t.Errorf("Eintrag nach dem Überschreiben nicht sichtbar: %v", err)
|
|
}
|
|
if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 0 {
|
|
t.Error("Eintrag liegt nach dem Überschreiben noch im Papierkorb")
|
|
}
|
|
old, err := s.EntryVersionByNumber(ctx, newer.ID, 1)
|
|
if err != nil || old.Content != "gelöscht" {
|
|
t.Errorf("gelöschter Stand fehlt im Verlauf: %v / %q", err, old.Content)
|
|
}
|
|
|
|
// Ein normaler Schreibvorgang danach meldet nichts mehr vom Papierkorb.
|
|
more, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Neu", Content: "weiter", Source: SourceAgent})
|
|
if more.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 list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 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.
|
|
newer, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
|
|
if err != nil || newer.Version != 1 || newer.RestoredFromTrash {
|
|
t.Errorf("Neuanlage nach endgültigem Löschen: %+v, %v", newer, 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.
|
|
oneMonthAgo := time.Now().UTC().Add(-31 * 24 * time.Hour).Format(time.RFC3339Nano)
|
|
if _, err := s.write.ExecContext(ctx,
|
|
`UPDATE entries SET deleted_at = ? WHERE slug = 'alt'`, oneMonthAgo); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
limit := time.Now().UTC().Add(-TrashRetention).Format(time.RFC3339Nano)
|
|
removed, err := s.PurgeTrash(ctx, limit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(removed[p.ID]) != 1 || removed[p.ID][0] != "alt" {
|
|
t.Errorf("entfernt: %v, erwartet nur „alt“", removed)
|
|
}
|
|
|
|
if list, _ := s.ListTrash(ctx, p.ID, ""); len(list) != 1 || list[0].Slug != "frisch" {
|
|
t.Errorf("im Papierkorb übrig: %v, erwartet „frisch“", list)
|
|
}
|
|
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)
|
|
foreignProject, 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})
|
|
foreign, _ := s.PutEntry(ctx, EntryWrite{ProjectID: foreignProject.ID, Slug: "f", Title: "F", Content: "x", Source: SourceHuman})
|
|
|
|
deleted, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID, foreign.ID, "gibtesnicht"}, "mensch")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(deleted) != 2 || deleted[0] != "a" || deleted[1] != "b" {
|
|
t.Errorf("gelöscht = %v, erwartet [a b]", deleted)
|
|
}
|
|
|
|
if _, err := s.EntryBySlug(ctx, foreignProject.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)
|
|
}
|
|
|
|
trash, err := s.ListTrash(ctx, p.ID, "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(trash) != 2 || trash[0].DeletedBy != "mensch" {
|
|
t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", trash)
|
|
}
|
|
|
|
// Doppelt abgeschickt: nichts mehr zu tun, kein Fehler.
|
|
again, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch")
|
|
if err != nil || len(again) != 0 {
|
|
t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", again, err)
|
|
}
|
|
}
|