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") } }