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