munin/internal/store/related_test.go
Thomas Adamek cf35c3f5db Verwandte Einträge, Aktualität, Importer (Ausbaustufen 3.7, 3.5, 3.2)
- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
  Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
  recall/get/list/related "stale": true; get und recall geben einen
  Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
  (Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
  Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
  Prüfvermerk und Zeitstempel aus dem Export.

Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 08:18:47 +02:00

197 lines
5.6 KiB
Go

package store
import (
"context"
"errors"
"testing"
"time"
)
// schreibe legt einen Eintrag an und bricht den Test bei einem Fehler ab.
func schreibe(t *testing.T, s *Store, projectID, slug, content string) {
t.Helper()
if _, err := s.PutEntry(context.Background(), EntryWrite{
ProjectID: projectID, Slug: slug, Title: "Titel " + slug, Content: content,
Source: SourceHuman, Author: "test",
}); err != nil {
t.Fatalf("Eintrag %q: %v", slug, err)
}
}
// TestRelatedEntries prüft Nachbarn im Verweisgraphen:
//
// start → a → fern
// b → start
// start → fehlt (Verweis ohne Eintrag)
// c (unverknüpft)
func TestRelatedEntries(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[a]] und [[fehlt]]")
schreibe(t, s, p.ID, "a", "weiter bei [[fern]]")
schreibe(t, s, p.ID, "b", "hängt an [[start]]")
schreibe(t, s, p.ID, "fern", "Ende")
schreibe(t, s, p.ID, "c", "allein")
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
if err != nil {
t.Fatal(err)
}
gefunden := make(map[string]RelatedEntry)
for _, v := range verwandte {
gefunden[v.Slug] = v
}
erwartet := map[string]struct {
abstand int
via string
relation string
}{
"a": {1, "start", RelationLinksTo},
"b": {1, "start", RelationLinkedBy},
"fern": {2, "a", RelationLinksTo},
}
for slug, e := range erwartet {
v, ok := gefunden[slug]
if !ok {
t.Errorf("%q fehlt", slug)
continue
}
if v.Distance != e.abstand || v.Via != e.via || v.Relation != e.relation {
t.Errorf("%q: Abstand %d über %q (%s), erwartet %d über %q (%s)",
slug, v.Distance, v.Via, v.Relation, e.abstand, e.via, e.relation)
}
if v.Title != "Titel "+slug || v.UpdatedAt.IsZero() {
t.Errorf("%q: Titel oder Änderungsdatum fehlen: %+v", slug, v)
}
}
for _, slug := range []string{"start", "fehlt", "c"} {
if _, ok := gefunden[slug]; ok {
t.Errorf("%q darf nicht unter den verwandten Einträgen stehen", slug)
}
}
// Näheres vor Fernerem.
if verwandte[len(verwandte)-1].Slug != "fern" {
t.Errorf("die zweite Reihe steht nicht am Ende: %+v", verwandte)
}
// Mit Abstand 1 nur die direkten Nachbarn.
direkt, err := s.RelatedEntries(ctx, p.ID, "start", 1, 20)
if err != nil {
t.Fatal(err)
}
if len(direkt) != 2 {
t.Errorf("Abstand 1 lieferte %d Einträge, erwartet 2: %+v", len(direkt), direkt)
}
// Die Obergrenze gilt.
begrenzt, err := s.RelatedEntries(ctx, p.ID, "start", 2, 1)
if err != nil {
t.Fatal(err)
}
if len(begrenzt) != 1 {
t.Errorf("Limit 1 lieferte %d Einträge", len(begrenzt))
}
}
// TestRelatedEntriesSkipsTrash prüft, dass gelöschte Einträge weder als
// Nachbar erscheinen noch als Brücke in die zweite Reihe dienen.
func TestRelatedEntriesSkipsTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
schreibe(t, s, p.ID, "start", "siehe [[weg]]")
schreibe(t, s, p.ID, "weg", "siehe [[dahinter]]")
schreibe(t, s, p.ID, "dahinter", "Ende")
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "test"); err != nil {
t.Fatal(err)
}
verwandte, err := s.RelatedEntries(ctx, p.ID, "start", 2, 20)
if err != nil {
t.Fatal(err)
}
if len(verwandte) != 0 {
t.Errorf("über einen gelöschten Eintrag wurde etwas gefunden: %+v", verwandte)
}
// Ein Ausgangseintrag im Papierkorb gilt als nicht vorhanden.
if _, err := s.RelatedEntries(ctx, p.ID, "weg", 2, 20); !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
}
func TestRelatedEntriesUnknownSlug(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
if _, err := s.RelatedEntries(context.Background(), p.ID, "gibtesnicht", 2, 20); !errors.Is(err, ErrNotFound) {
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
}
}
// TestEntryStale prüft die Grenze für veraltetes Wissen (AUSBAUSTUFEN.md 3.5).
func TestEntryStale(t *testing.T) {
jetzt := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
faelle := []struct {
name string
geaendert time.Time
erwartet bool
}{
{"gestern", jetzt.AddDate(0, 0, -1), false},
{"vor elf Monaten", jetzt.AddDate(0, -11, 0), false},
{"vor zwei Jahren", jetzt.AddDate(-2, 0, 0), true},
{"ohne Datum", time.Time{}, false},
}
for _, f := range faelle {
t.Run(f.name, func(t *testing.T) {
if got := (Entry{UpdatedAt: f.geaendert}).Stale(jetzt); got != f.erwartet {
t.Errorf("Stale = %v, erwartet %v", got, f.erwartet)
}
})
}
}
// TestPutEntryKeepsImportedTimestamps prüft, dass vorgegebene Zeitstempel
// beim Anlegen übernommen und beim Ändern ignoriert werden.
func TestPutEntryKeepsImportedTimestamps(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
alt := time.Date(2023, 3, 11, 9, 0, 0, 0, time.UTC)
e, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "x",
Source: SourceHuman, CreatedAt: alt, UpdatedAt: alt,
})
if err != nil {
t.Fatal(err)
}
gelesen, _ := s.EntryBySlug(ctx, p.ID, "alt")
if !gelesen.CreatedAt.Equal(alt) || !gelesen.UpdatedAt.Equal(alt) || !e.UpdatedAt.Equal(alt) {
t.Errorf("Zeitstempel nicht übernommen: angelegt %v, geändert %v", gelesen.CreatedAt, gelesen.UpdatedAt)
}
// Eine Änderung passiert jetzt, egal was mitgegeben wird.
if _, err := s.PutEntry(ctx, EntryWrite{
ProjectID: p.ID, Slug: "alt", Title: "Alt", Content: "neu",
Source: SourceHuman, UpdatedAt: alt,
}); err != nil {
t.Fatal(err)
}
gelesen, _ = s.EntryBySlug(ctx, p.ID, "alt")
if gelesen.UpdatedAt.Equal(alt) {
t.Error("beim Ändern wurde der vorgegebene Zeitstempel übernommen")
}
if !gelesen.CreatedAt.Equal(alt) {
t.Error("beim Ändern ging das Anlegedatum verloren")
}
}