- 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>
197 lines
5.6 KiB
Go
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")
|
|
}
|
|
}
|