Umsetzung der Ausbaustufen 2.1, 3.6, 3.8, 3.9, 4.1, 4.2, 4.4, 4.6, 5.1–5.4 (Details in AUSBAUSTUFEN.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1054 lines
34 KiB
Go
1054 lines
34 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// testStore öffnet eine frische Datenbank in einem Testverzeichnis.
|
|
//
|
|
// Bewusst eine echte Datei statt ":memory:" — so wird auch geprüft, dass
|
|
// Migrationen, WAL-Modus und Fremdschlüssel wirklich funktionieren. t.TempDir
|
|
// räumt hinterher auf.
|
|
func testStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
|
|
s, err := Open(filepath.Join(t.TempDir(), "test.db"))
|
|
if err != nil {
|
|
t.Fatalf("Datenbank konnte nicht geöffnet werden: %v", err)
|
|
}
|
|
t.Cleanup(func() { s.Close() })
|
|
return s
|
|
}
|
|
|
|
// testWorkspace liefert eine Workspace-ID für Tests.
|
|
//
|
|
// Eine Workspace-Datei kennt keine "workspaces"-Tabelle mehr (die lebt in der
|
|
// zentralen Kontroll-Datenbank, siehe control.go) — für Tests reicht deshalb
|
|
// eine beliebige ID, ohne dass eine echte ControlStore-Instanz nötig wäre.
|
|
func testWorkspace() Workspace {
|
|
return Workspace{ID: NewID(), Name: "Test"}
|
|
}
|
|
|
|
// testProject legt Workspace und Projekt an — die Grundlage fast aller Tests.
|
|
func testProject(t *testing.T, s *Store) Project {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
|
|
ws := testWorkspace()
|
|
p, err := s.CreateProject(ctx, ws.ID, "test", "Testprojekt", "")
|
|
if err != nil {
|
|
t.Fatalf("Projekt: %v", err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ── Migrationen ──────────────────────────────────────────────────────────────
|
|
|
|
// TestMigrationsAreIdempotent prüft, dass ein zweiter Start dieselbe Datenbank
|
|
// nicht kaputt macht — der Normalfall bei jedem Neustart des Servers.
|
|
func TestMigrationsAreIdempotent(t *testing.T) {
|
|
pfad := filepath.Join(t.TempDir(), "test.db")
|
|
|
|
s1, err := Open(pfad)
|
|
if err != nil {
|
|
t.Fatalf("erster Start: %v", err)
|
|
}
|
|
p := testProject(t, s1)
|
|
s1.Close()
|
|
|
|
s2, err := Open(pfad)
|
|
if err != nil {
|
|
t.Fatalf("zweiter Start: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
|
|
// Die Daten aus dem ersten Start müssen noch da sein.
|
|
if _, err := s2.ProjectByID(context.Background(), p.ID); err != nil {
|
|
t.Errorf("Projekt nach Neustart nicht mehr auffindbar: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestMigrationBestandsdaten prüft, dass eine mit Fassung 001 angelegte
|
|
// Datenbank sauber auf 002 gehoben wird — der Fall bei jedem Update.
|
|
func TestMigrationBestandsdaten(t *testing.T) {
|
|
pfad := filepath.Join(t.TempDir(), "alt.db")
|
|
|
|
s, err := Open(pfad)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ctx := context.Background()
|
|
ws := testWorkspace()
|
|
p, _ := s.CreateProject(ctx, ws.ID, "alt", "Alt", "")
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel",
|
|
Content: "x", Tags: []string{"ops"}, Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle", Title: "Quelle",
|
|
Content: "siehe [[ziel]]", Tags: []string{"ops", "docs"}, Source: SourceHuman})
|
|
s.Close()
|
|
|
|
// Erneut öffnen — hier läuft die Migration bei Bestandsdaten.
|
|
s2, err := Open(pfad)
|
|
if err != nil {
|
|
t.Fatalf("Migration fehlgeschlagen: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
|
|
// project_id muss in beiden Nebentabellen nachgetragen worden sein.
|
|
var leereTags, leereLinks int
|
|
s2.lesen.QueryRow(`SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&leereTags)
|
|
s2.lesen.QueryRow(`SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&leereLinks)
|
|
if leereTags != 0 || leereLinks != 0 {
|
|
t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", leereTags, leereLinks)
|
|
}
|
|
|
|
// Und die Abfragen liefern weiterhin das Richtige.
|
|
tags, err := s2.ListTags(ctx, p.ID)
|
|
if err != nil || len(tags) != 2 {
|
|
t.Errorf("ListTags nach Migration: %v (%d Schlagworte, erwartet 2)", err, len(tags))
|
|
}
|
|
links, err := s2.Backlinks(ctx, p.ID, "ziel")
|
|
if err != nil || len(links) != 1 {
|
|
t.Errorf("Backlinks nach Migration: %v (%d Verweise, erwartet 1)", err, len(links))
|
|
}
|
|
}
|
|
|
|
// TestGleichzeitigeSchreibvorgaenge ist der Regressionstest zu den zwei
|
|
// Verbindungspools (siehe Store in store.go).
|
|
//
|
|
// Mit einem gemeinsamen Pool für Lesen und Schreiben scheiterten hier 296 von
|
|
// 320 Schreibvorgängen mit "database is locked" — mehrere Verbindungen begannen
|
|
// gleichzeitig eine Schreibtransaktion und liefen mitten drin auf die Sperre,
|
|
// wo busy_timeout nicht mehr greift.
|
|
func TestGleichzeitigeSchreibvorgaenge(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
ctx := context.Background()
|
|
|
|
const agenten, jeAgent = 8, 40
|
|
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var fehler []error
|
|
|
|
for a := 0; a < agenten; a++ {
|
|
wg.Add(1)
|
|
go func(a int) {
|
|
defer wg.Done()
|
|
for i := 0; i < jeAgent; i++ {
|
|
_, err := s.PutEntry(ctx, EntryWrite{
|
|
ProjectID: p.ID,
|
|
Slug: fmt.Sprintf("agent%d-%03d", a, i),
|
|
Title: "Eintrag",
|
|
Content: "Inhalt",
|
|
Source: SourceAgent,
|
|
Author: fmt.Sprintf("agent-%d", a),
|
|
})
|
|
if err != nil {
|
|
mu.Lock()
|
|
fehler = append(fehler, err)
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}(a)
|
|
}
|
|
wg.Wait()
|
|
|
|
if len(fehler) > 0 {
|
|
t.Errorf("%d von %d Schreibvorgängen fehlgeschlagen, erster: %v",
|
|
len(fehler), agenten*jeAgent, fehler[0])
|
|
}
|
|
|
|
n, err := s.CountEntries(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != agenten*jeAgent {
|
|
t.Errorf("%d Einträge angelegt, erwartet %d", n, agenten*jeAgent)
|
|
}
|
|
}
|
|
|
|
// TestGleichzeitigesLesenUndSchreiben prüft, dass Lesezugriffe nicht hinter
|
|
// Schreibvorgängen warten müssen — der Grund für den getrennten Lesepool.
|
|
func TestGleichzeitigesLesenUndSchreiben(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
ctx := context.Background()
|
|
|
|
for i := 0; i < 50; i++ {
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: fmt.Sprintf("e-%03d", i),
|
|
Title: "T", Content: "Inhalt", Source: SourceHuman})
|
|
}
|
|
|
|
var wg sync.WaitGroup
|
|
var leseFehler, schreibFehler int64
|
|
|
|
// Schreiber und Leser gleichzeitig laufen lassen.
|
|
for i := 0; i < 4; i++ {
|
|
wg.Add(2)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
for j := 0; j < 25; j++ {
|
|
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID,
|
|
Slug: fmt.Sprintf("neu-%d-%03d", i, j), Title: "T",
|
|
Content: "Inhalt", Source: SourceAgent}); err != nil {
|
|
atomic.AddInt64(&schreibFehler, 1)
|
|
}
|
|
}
|
|
}(i)
|
|
go func() {
|
|
defer wg.Done()
|
|
for j := 0; j < 100; j++ {
|
|
if _, err := s.ListEntries(ctx, p.ID, EntryFilter{Limit: 20}); err != nil {
|
|
atomic.AddInt64(&leseFehler, 1)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if leseFehler > 0 || schreibFehler > 0 {
|
|
t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", leseFehler, schreibFehler)
|
|
}
|
|
}
|
|
|
|
// ── Workspace und Projekte ───────────────────────────────────────────────────
|
|
//
|
|
// Die Stabilität von EnsureDefaultWorkspace wird in control_test.go geprüft —
|
|
// die Funktion gehört zur zentralen Kontroll-Datenbank, nicht zu dieser Datei.
|
|
|
|
func TestCreateProjectRejectsBadSlug(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
ws := testWorkspace()
|
|
|
|
for _, slug := range []string{"", "Groß", "mit leer", "../raus", "a/b", strings.Repeat("x", 65)} {
|
|
if _, err := s.CreateProject(ctx, ws.ID, slug, "X", ""); err == nil {
|
|
t.Errorf("ungültiger Bezeichner %q wurde akzeptiert", slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreateProjectRejectsDuplicateSlug(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
ws := testWorkspace()
|
|
|
|
if _, err := s.CreateProject(ctx, ws.ID, "doppelt", "Erst", ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err := s.CreateProject(ctx, ws.ID, "doppelt", "Zweit", "")
|
|
if !errors.Is(err, ErrDuplicate) {
|
|
t.Errorf("Fehler = %v, erwartet ErrDuplicate", err)
|
|
}
|
|
}
|
|
|
|
// TestProjectWriteLimitDefaultsToZero prüft die Vorgabe aus Migration 003.
|
|
// 0 heißt nicht "gesperrt", sondern "keine eigene Angabe" — nur so bekommen
|
|
// bestehende Projekte nach der Migration den Standard aus der Umgebung.
|
|
func TestProjectWriteLimitDefaultsToZero(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
|
|
if p.WriteLimitHourly != 0 {
|
|
t.Errorf("WriteLimitHourly = %d bei einem neuen Projekt, erwartet 0", p.WriteLimitHourly)
|
|
}
|
|
}
|
|
|
|
func TestUpdateProject(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
ctx := context.Background()
|
|
|
|
if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{
|
|
Name: "Neuer Name", Description: "Neue Beschreibung", WriteLimitHourly: 250,
|
|
}); err != nil {
|
|
t.Fatalf("Projekt ändern: %v", err)
|
|
}
|
|
|
|
gelesen, err := s.ProjectByID(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if gelesen.Name != "Neuer Name" || gelesen.Description != "Neue Beschreibung" {
|
|
t.Errorf("Name/Beschreibung = %q/%q", gelesen.Name, gelesen.Description)
|
|
}
|
|
if gelesen.WriteLimitHourly != 250 {
|
|
t.Errorf("WriteLimitHourly = %d, erwartet 250", gelesen.WriteLimitHourly)
|
|
}
|
|
|
|
// Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen.
|
|
if gelesen.Slug != p.Slug {
|
|
t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", gelesen.Slug, p.Slug)
|
|
}
|
|
|
|
// 0 nimmt die eigene Obergrenze zurück.
|
|
if err := s.UpdateProject(ctx, p.ID, ProjectUpdate{Name: "Neuer Name"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
gelesen, _ = s.ProjectByID(ctx, p.ID)
|
|
if gelesen.WriteLimitHourly != 0 {
|
|
t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", gelesen.WriteLimitHourly)
|
|
}
|
|
}
|
|
|
|
// TestUpdateProjectRejectsNegativeLimit: Eine negative Zahl würde im
|
|
// Schreibbudget als "abgeschaltet" gelesen — also das Gegenteil einer Grenze.
|
|
func TestUpdateProjectRejectsNegativeLimit(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
|
|
err := s.UpdateProject(context.Background(), p.ID, ProjectUpdate{Name: "Test", WriteLimitHourly: -1})
|
|
if err == nil {
|
|
t.Error("eine negative Obergrenze wurde angenommen")
|
|
}
|
|
}
|
|
|
|
func TestUpdateProjectRejectsEmptyName(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
|
|
if err := s.UpdateProject(context.Background(), p.ID, ProjectUpdate{Name: " "}); err == nil {
|
|
t.Error("ein leerer Name wurde angenommen")
|
|
}
|
|
}
|
|
|
|
func TestUpdateProjectUnknownID(t *testing.T) {
|
|
s := testStore(t)
|
|
|
|
err := s.UpdateProject(context.Background(), "gibtesnicht", ProjectUpdate{Name: "Test"})
|
|
if !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestProjectNotFound(t *testing.T) {
|
|
s := testStore(t)
|
|
if _, err := s.ProjectByID(context.Background(), "gibtsnicht"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("Fehler = %v, erwartet ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
// TestDeleteProjectCascades prüft, dass mit dem Projekt auch Keys und Einträge
|
|
// verschwinden. Das funktioniert nur, wenn foreign_keys eingeschaltet ist —
|
|
// dieser Test schlägt fehl, falls das PRAGMA je verloren geht.
|
|
func TestDeleteProjectCascades(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
if _, err := s.CreateAPIKey(ctx, p.ID, "test", "hash1", "write"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.PutEntry(ctx, EntryWrite{
|
|
ProjectID: p.ID, Slug: "eintrag", Title: "T", Content: "C", Source: SourceHuman,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.DeleteProject(ctx, p.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := s.ResolveKeyHash(ctx, "hash1"); !errors.Is(err, ErrNotFound) {
|
|
t.Error("der Key des gelöschten Projekts existiert noch")
|
|
}
|
|
if n, _ := s.CountEntries(ctx, p.ID); n != 0 {
|
|
t.Errorf("%d Einträge des gelöschten Projekts sind übrig", n)
|
|
}
|
|
}
|
|
|
|
// ── API-Keys ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestResolveKeyHash(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
k, err := s.CreateAPIKey(ctx, p.ID, "tom-cline", "hash-abc", "write")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
auf, err := s.ResolveKeyHash(ctx, "hash-abc")
|
|
if err != nil {
|
|
t.Fatalf("gültiger Key wurde nicht aufgelöst: %v", err)
|
|
}
|
|
if auf.Key.ID != k.ID {
|
|
t.Error("falscher Key aufgelöst")
|
|
}
|
|
// Das Projekt kommt in derselben Abfrage mit — sonst bräuchte jeder Request
|
|
// einen zweiten Datenbankzugriff.
|
|
if auf.Project.ID != p.ID {
|
|
t.Error("das Projekt wurde nicht mitgeliefert")
|
|
}
|
|
}
|
|
|
|
// TestRevokedKeyIsGone prüft, dass ein zurückgezogener Key sofort nicht mehr
|
|
// gilt — das ist der Ersatz für den früheren Fingerabdruck-Mechanismus.
|
|
func TestRevokedKeyIsGone(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
k, _ := s.CreateAPIKey(ctx, p.ID, "alt", "hash-alt", "write")
|
|
|
|
if _, err := s.ResolveKeyHash(ctx, "hash-alt"); err != nil {
|
|
t.Fatalf("Key galt von Anfang an nicht: %v", err)
|
|
}
|
|
|
|
if err := s.RevokeAPIKey(ctx, k.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := s.ResolveKeyHash(ctx, "hash-alt"); !errors.Is(err, ErrNotFound) {
|
|
t.Error("zurückgezogener Key gilt weiterhin")
|
|
}
|
|
|
|
// Der Datensatz bleibt aber erhalten — sonst verlöre das Audit-Log die Spur.
|
|
if _, err := s.APIKeyByID(ctx, k.ID); err != nil {
|
|
t.Errorf("der Key-Datensatz wurde gelöscht statt markiert: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCreateAPIKeyRequiresName(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
|
|
if _, err := s.CreateAPIKey(context.Background(), p.ID, " ", "hash", "read"); err == nil {
|
|
t.Error("ein Key ohne Namen wurde angelegt")
|
|
}
|
|
}
|
|
|
|
// ── Einträge ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestPutEntryCreatesAndUpdates(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
erst, err := s.PutEntry(ctx, EntryWrite{
|
|
ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
|
|
Content: "Erste Fassung", Tags: []string{"Ops", "ops", " Deploy "},
|
|
Source: SourceAgent, Author: "agent-1",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if erst.Version != 1 {
|
|
t.Errorf("Version = %d, erwartet 1", erst.Version)
|
|
}
|
|
// Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert.
|
|
if len(erst.Tags) != 2 || erst.Tags[0] != "deploy" || erst.Tags[1] != "ops" {
|
|
t.Errorf("Tags = %v, erwartet [deploy ops]", erst.Tags)
|
|
}
|
|
|
|
// Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der
|
|
// Dublettenvermeidung.
|
|
zweit, err := s.PutEntry(ctx, EntryWrite{
|
|
ProjectID: p.ID, Slug: "deployment", Title: "Deployment",
|
|
Content: "Zweite Fassung", Source: SourceAgent, Author: "agent-2",
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if zweit.Version != 2 {
|
|
t.Errorf("Version = %d, erwartet 2", zweit.Version)
|
|
}
|
|
if zweit.ID != erst.ID {
|
|
t.Error("es wurde ein zweiter Eintrag angelegt statt zu aktualisieren")
|
|
}
|
|
if n, _ := s.CountEntries(ctx, p.ID); n != 1 {
|
|
t.Errorf("%d Einträge, erwartet 1", n)
|
|
}
|
|
// Die Schlagworte des ersten Schreibvorgangs sind weg, weil der zweite keine
|
|
// mitgebracht hat — der Eintrag ist die Wahrheit, nicht die Summe der Änderungen.
|
|
if len(zweit.Tags) != 0 {
|
|
t.Errorf("Tags = %v, erwartet leer", zweit.Tags)
|
|
}
|
|
}
|
|
|
|
// TestPutEntryOptimisticLocking prüft den Schutz vor stillem Überschreiben.
|
|
func TestPutEntryOptimisticLocking(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
basis := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent}
|
|
|
|
e, err := s.PutEntry(ctx, basis)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Agent A schreibt auf Basis von Version 1 — geht durch.
|
|
a := basis
|
|
a.Content = "von A"
|
|
a.ExpectedVersion = e.Version
|
|
if _, err := s.PutEntry(ctx, a); err != nil {
|
|
t.Fatalf("erster Schreibvorgang scheiterte: %v", err)
|
|
}
|
|
|
|
// Agent B hatte ebenfalls Version 1 gelesen — muss jetzt einen Konflikt bekommen.
|
|
b := basis
|
|
b.Content = "von B"
|
|
b.ExpectedVersion = e.Version
|
|
if _, err := s.PutEntry(ctx, b); !errors.Is(err, ErrConflict) {
|
|
t.Errorf("Fehler = %v, erwartet ErrConflict — B hat A stillschweigend überschrieben", err)
|
|
}
|
|
}
|
|
|
|
// TestPutEntryExpectedVersionOnMissingEntry: Wer einen Stand fortschreiben will,
|
|
// den es nicht mehr gibt, bekommt einen Konflikt statt eines neuen Eintrags.
|
|
func TestPutEntryExpectedVersionOnMissingEntry(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
|
|
_, err := s.PutEntry(context.Background(), EntryWrite{
|
|
ProjectID: p.ID, Slug: "geloescht", Title: "T", Source: SourceAgent, ExpectedVersion: 3,
|
|
})
|
|
if !errors.Is(err, ErrConflict) {
|
|
t.Errorf("Fehler = %v, erwartet ErrConflict", err)
|
|
}
|
|
}
|
|
|
|
func TestPutEntryValidation(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
cases := []struct {
|
|
name string
|
|
w EntryWrite
|
|
}{
|
|
{"leerer Slug", EntryWrite{ProjectID: p.ID, Title: "T", Source: SourceHuman}},
|
|
{"Slug mit Pfad", EntryWrite{ProjectID: p.ID, Slug: "../raus", Title: "T", Source: SourceHuman}},
|
|
{"Slug groß", EntryWrite{ProjectID: p.ID, Slug: "Groß", Title: "T", Source: SourceHuman}},
|
|
{"kein Titel", EntryWrite{ProjectID: p.ID, Slug: "ok", Source: SourceHuman}},
|
|
{"unbekannte Herkunft", EntryWrite{ProjectID: p.ID, Slug: "ok", Title: "T", Source: "irgendwas"}},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := s.PutEntry(ctx, tc.w); err == nil {
|
|
t.Error("ungültiger Schreibvorgang wurde akzeptiert")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEntriesAreProjectScoped ist der Isolationstest: derselbe Slug in zwei
|
|
// Projekten sind zwei verschiedene Einträge, und keiner sieht den anderen.
|
|
func TestEntriesAreProjectScoped(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
ws := testWorkspace()
|
|
|
|
a, _ := s.CreateProject(ctx, ws.ID, "projekt-a", "A", "")
|
|
b, _ := s.CreateProject(ctx, ws.ID, "projekt-b", "B", "")
|
|
|
|
if _, err := s.PutEntry(ctx, EntryWrite{
|
|
ProjectID: a.ID, Slug: "geheim", Title: "A-Wissen", Content: "nur für A", Source: SourceHuman,
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if _, err := s.EntryBySlug(ctx, b.ID, "geheim"); !errors.Is(err, ErrNotFound) {
|
|
t.Error("Projekt B sieht einen Eintrag von Projekt A")
|
|
}
|
|
|
|
// Derselbe Slug muss in B trotzdem frei sein.
|
|
if _, err := s.PutEntry(ctx, EntryWrite{
|
|
ProjectID: b.ID, Slug: "geheim", Title: "B-Wissen", Content: "nur für B", Source: SourceHuman,
|
|
}); err != nil {
|
|
t.Errorf("derselbe Slug ließ sich in Projekt B nicht anlegen: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestListEntriesFilterByTag(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Tags: []string{"ops"}, Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"docs"}, Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Tags: []string{"ops", "docs"}, Source: SourceHuman})
|
|
|
|
gefiltert, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(gefiltert) != 2 {
|
|
t.Errorf("%d Treffer, erwartet 2", len(gefiltert))
|
|
}
|
|
|
|
// Groß-/Kleinschreibung darf keine Rolle spielen.
|
|
grossGeschrieben, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"})
|
|
if len(grossGeschrieben) != 2 {
|
|
t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(grossGeschrieben))
|
|
}
|
|
}
|
|
|
|
func TestListTagsCounts(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Tags: []string{"ops"}, Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Tags: []string{"ops", "docs"}, Source: SourceHuman})
|
|
|
|
tags, err := s.ListTags(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(tags) != 2 {
|
|
t.Fatalf("%d Schlagworte, erwartet 2", len(tags))
|
|
}
|
|
// Häufigste zuerst.
|
|
if tags[0].Tag != "ops" || tags[0].Count != 2 {
|
|
t.Errorf("erstes Schlagwort = %+v, erwartet {ops 2}", tags[0])
|
|
}
|
|
}
|
|
|
|
func TestMoveEntryToTrash(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "T", Source: SourceHuman})
|
|
|
|
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.EntryBySlug(ctx, p.ID, "weg"); !errors.Is(err, ErrNotFound) {
|
|
t.Error("der Eintrag ist nach dem Löschen noch sichtbar")
|
|
}
|
|
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "tom"); !errors.Is(err, ErrNotFound) {
|
|
t.Error("zweimaliges Löschen meldete keinen Fehler")
|
|
}
|
|
}
|
|
|
|
// ── Verweise ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestExtractLinks(t *testing.T) {
|
|
cases := map[string][]string{
|
|
"": nil,
|
|
"ohne Verweise": nil,
|
|
"siehe [[deployment]]": {"deployment"},
|
|
"[[b]] und [[a]] und nochmal [[a]]": {"a", "b"},
|
|
"[[Großbuchstaben]] zählen nicht": nil,
|
|
"[[mit leerzeichen]] auch nicht": nil,
|
|
"[[a-b_c1]] ist erlaubt": {"a-b_c1"},
|
|
"einfache [klammern] sind kein Verweis": nil,
|
|
}
|
|
|
|
for input, erwartet := range cases {
|
|
got := ExtractLinks(input)
|
|
if len(got) != len(erwartet) {
|
|
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != erwartet[i] {
|
|
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, erwartet)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBacklinks prüft die Gegenrichtung der Verweise — sie ersetzt die frühere
|
|
// Ordnerhierarchie als Struktur zwischen Einträgen.
|
|
func TestBacklinks(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ziel", Title: "Ziel", Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-a", Title: "Quelle A",
|
|
Content: "siehe [[ziel]]", Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-b", Title: "Quelle B",
|
|
Content: "auch [[ziel]]", Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "ohne", Title: "Ohne", Source: SourceHuman})
|
|
|
|
links, err := s.Backlinks(ctx, p.ID, "ziel")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(links) != 2 {
|
|
t.Fatalf("%d Rückverweise, erwartet 2", len(links))
|
|
}
|
|
|
|
// Verweise verschwinden, wenn sie aus dem Inhalt entfernt werden.
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "quelle-a", Title: "Quelle A",
|
|
Content: "ohne Verweis", Source: SourceHuman})
|
|
|
|
links, _ = s.Backlinks(ctx, p.ID, "ziel")
|
|
if len(links) != 1 {
|
|
t.Errorf("%d Rückverweise nach dem Entfernen, erwartet 1", len(links))
|
|
}
|
|
}
|
|
|
|
// ── Audit ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestAuditRoundTrip(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "tom", Action: ActionEntryWrite,
|
|
EntrySlug: "a", Source: SourceWeb, IP: "10.0.0.1"})
|
|
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryDelete,
|
|
EntrySlug: "b", Source: SourceMCP})
|
|
|
|
ereignisse, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(ereignisse) != 2 {
|
|
t.Fatalf("%d Ereignisse, erwartet 2", len(ereignisse))
|
|
}
|
|
// Neueste zuerst.
|
|
if ereignisse[0].Action != ActionEntryDelete {
|
|
t.Errorf("erstes Ereignis = %q, erwartet %q", ereignisse[0].Action, ActionEntryDelete)
|
|
}
|
|
if ereignisse[0].Timestamp.IsZero() {
|
|
t.Error("Zeitstempel wurde nicht gesetzt")
|
|
}
|
|
}
|
|
|
|
// TestPruneAudit prüft die Aufbewahrungsfrist — ohne sie wächst das Protokoll
|
|
// unbegrenzt, und personenbezogene Daten dürften gar nicht so lange liegen.
|
|
func TestPruneAudit(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: "test", Source: SourceSystem})
|
|
|
|
// Grenze in der Zukunft — alles muss weg.
|
|
zukunft := time.Now().UTC().Add(time.Hour).Format(timeFormat)
|
|
n, err := s.PruneAudit(ctx, zukunft)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("%d Zeilen gelöscht, erwartet 1", n)
|
|
}
|
|
|
|
rest, _ := s.ListAudit(ctx, AuditFilter{})
|
|
if len(rest) != 0 {
|
|
t.Errorf("%d Ereignisse übrig", len(rest))
|
|
}
|
|
}
|
|
|
|
// ── Hilfsfunktionen ──────────────────────────────────────────────────────────
|
|
|
|
func TestSlugify(t *testing.T) {
|
|
cases := map[string]string{
|
|
"Deployment Prozess": "deployment-prozess",
|
|
"Größe & Übersicht": "groesse-uebersicht",
|
|
" mehrere Wörter ": "mehrere-woerter",
|
|
"MIT-Bindestrich": "mit-bindestrich",
|
|
"!!!": "",
|
|
"straße": "strasse",
|
|
}
|
|
|
|
for input, erwartet := range cases {
|
|
if got := Slugify(input); got != erwartet {
|
|
t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, erwartet)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewIDIsSortableAndUnique(t *testing.T) {
|
|
gesehen := make(map[string]bool)
|
|
var vorher string
|
|
|
|
for i := 0; i < 200; i++ {
|
|
id := NewID()
|
|
if gesehen[id] {
|
|
t.Fatalf("doppelte ID: %s", id)
|
|
}
|
|
gesehen[id] = true
|
|
|
|
// Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar.
|
|
if vorher != "" && id < vorher {
|
|
zeitVorher, _, _ := strings.Cut(vorher, "-")
|
|
zeitJetzt, _, _ := strings.Cut(id, "-")
|
|
if zeitJetzt < zeitVorher {
|
|
t.Errorf("IDs sind nicht chronologisch: %s vor %s", vorher, id)
|
|
}
|
|
}
|
|
vorher = id
|
|
}
|
|
}
|
|
|
|
func TestNormalizeTags(t *testing.T) {
|
|
got := NormalizeTags([]string{" Ops ", "ops", "DOCS", "", " "})
|
|
if len(got) != 2 || got[0] != "docs" || got[1] != "ops" {
|
|
t.Errorf("NormalizeTags = %v, erwartet [docs ops]", got)
|
|
}
|
|
}
|
|
|
|
func TestBulkUpdateTags(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A",
|
|
Tags: []string{"alt"}, Source: SourceHuman})
|
|
b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B",
|
|
Tags: []string{"alt", "bleibt"}, Source: SourceHuman})
|
|
// Dritter Eintrag bleibt unangetastet — zur Probe, dass BulkUpdateTags nur
|
|
// trifft, wer ausgewählt wurde.
|
|
c, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
|
|
Tags: []string{"alt"}, Source: SourceHuman})
|
|
|
|
geaendert, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(geaendert) != 2 {
|
|
t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(geaendert))
|
|
}
|
|
|
|
neuA, _ := s.EntryBySlug(ctx, p.ID, "a")
|
|
if !stringSlicesEqual(neuA.Tags, []string{"neu"}) {
|
|
t.Errorf("Tags von A = %v, erwartet [neu]", neuA.Tags)
|
|
}
|
|
if neuA.Version != 2 {
|
|
t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", neuA.Version)
|
|
}
|
|
|
|
neuB, _ := s.EntryBySlug(ctx, p.ID, "b")
|
|
if !stringSlicesEqual(neuB.Tags, []string{"bleibt", "neu"}) {
|
|
t.Errorf("Tags von B = %v, erwartet [bleibt neu]", neuB.Tags)
|
|
}
|
|
|
|
unveraendertC, _ := s.EntryBySlug(ctx, p.ID, "c")
|
|
if !stringSlicesEqual(unveraendertC.Tags, []string{"alt"}) {
|
|
t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unveraendertC.Tags)
|
|
}
|
|
if unveraendertC.Version != c.Version {
|
|
t.Error("Version von C wurde erhöht, obwohl C nicht ausgewählt war")
|
|
}
|
|
}
|
|
|
|
func TestBulkUpdateTagsIgnoresForeignProjectIDs(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
ws := testWorkspace()
|
|
|
|
a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "")
|
|
b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "")
|
|
|
|
fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "fremd", Title: "Fremd",
|
|
Tags: []string{"x"}, Source: SourceHuman})
|
|
|
|
// Aufruf mit dem Projekt A, aber einer ID aus Projekt B — darf nichts
|
|
// bewirken, sonst könnte ein untergeschobenes Formularfeld projektübergreifend
|
|
// Einträge verändern.
|
|
geaendert, err := s.BulkUpdateTags(ctx, a.ID, []string{fremd.ID}, []string{"neu"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(geaendert) != 0 {
|
|
t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(geaendert))
|
|
}
|
|
|
|
unveraendert, _ := s.EntryBySlug(ctx, b.ID, "fremd")
|
|
if !stringSlicesEqual(unveraendert.Tags, []string{"x"}) {
|
|
t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unveraendert.Tags)
|
|
}
|
|
}
|
|
|
|
// ── Änderungshistorie ────────────────────────────────────────────────────────
|
|
|
|
func TestPutEntryRecordsHistory(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
erste, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Erste Fassung",
|
|
Content: "Inhalt 1", Tags: []string{"a"}, Source: SourceHuman, Author: "tom"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Beim erstmaligen Anlegen gibt es noch nichts zu sichern.
|
|
historie, err := s.EntryVersions(ctx, erste.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(historie) != 0 {
|
|
t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(historie))
|
|
}
|
|
|
|
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Zweite Fassung",
|
|
Content: "Inhalt 2", Tags: []string{"b"}, Source: SourceHuman, Author: "tom"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "Dritte Fassung",
|
|
Content: "Inhalt 3", Tags: []string{"c"}, Source: SourceHuman, Author: "tom"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
historie, err = s.EntryVersions(ctx, erste.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(historie) != 2 {
|
|
t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(historie))
|
|
}
|
|
// Neueste zuerst.
|
|
if historie[0].Version != 2 || historie[0].Title != "Zweite Fassung" {
|
|
t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", historie[0].Version, historie[0].Title)
|
|
}
|
|
if historie[1].Version != 1 || historie[1].Title != "Erste Fassung" {
|
|
t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", historie[1].Version, historie[1].Title)
|
|
}
|
|
if historie[1].Content != "Inhalt 1" || !stringSlicesEqual(historie[1].Tags, []string{"a"}) {
|
|
t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", historie[1].Content, historie[1].Tags)
|
|
}
|
|
|
|
fassung1, err := s.EntryVersionByNumber(ctx, erste.ID, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fassung1.Content != "Inhalt 1" {
|
|
t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", fassung1.Content)
|
|
}
|
|
|
|
if _, err := s.EntryVersionByNumber(ctx, erste.ID, 99); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("unbekannte Fassung: err = %v, erwartet ErrNotFound", err)
|
|
}
|
|
}
|
|
|
|
func TestBulkUpdateTagsRecordsHistory(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T",
|
|
Content: "Inhalt", Tags: []string{"alt"}, Source: SourceHuman})
|
|
|
|
if _, err := s.BulkUpdateTags(ctx, p.ID, []string{e.ID}, []string{"neu"}, []string{"alt"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
historie, err := s.EntryVersions(ctx, e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(historie) != 1 {
|
|
t.Fatalf("%d Fassungen, erwartet 1", len(historie))
|
|
}
|
|
if !stringSlicesEqual(historie[0].Tags, []string{"alt"}) {
|
|
t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", historie[0].Tags)
|
|
}
|
|
}
|
|
|
|
// TestTrashKeepsHistoryPurgeRemovesIt: Der Papierkorb behält den Verlauf,
|
|
// erst endgültiges Löschen entfernt ihn (CASCADE).
|
|
func TestTrashKeepsHistoryPurgeRemovesIt(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
e, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "1", Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "e", Title: "T", Content: "2", Source: SourceHuman})
|
|
|
|
if err := s.MoveEntryToTrash(ctx, p.ID, "e", "tom"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if historie, _ := s.EntryVersions(ctx, e.ID); len(historie) != 1 {
|
|
t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(historie))
|
|
}
|
|
|
|
if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 {
|
|
t.Fatalf("PurgeFromTrash: %d, %v", n, err)
|
|
}
|
|
|
|
historie, err := s.EntryVersions(ctx, e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(historie) != 0 {
|
|
t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(historie))
|
|
}
|
|
}
|
|
|
|
// ── Graph ────────────────────────────────────────────────────────────────────
|
|
|
|
func TestLinkGraph(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A",
|
|
Content: "verweist auf [[b]] und [[fehlt]]", Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B",
|
|
Content: "verweist zurück auf [[a]]", Source: SourceHuman})
|
|
// Eintrag ohne jeden Verweis — muss trotzdem als Knoten auftauchen.
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C",
|
|
Content: "allein", Source: SourceHuman})
|
|
|
|
knoten, kanten, err := s.LinkGraph(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(knoten) != 4 {
|
|
t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(knoten))
|
|
}
|
|
|
|
nachSlug := make(map[string]GraphNode, len(knoten))
|
|
for _, k := range knoten {
|
|
nachSlug[k.Slug] = k
|
|
}
|
|
|
|
if !nachSlug["a"].Exists || !nachSlug["b"].Exists || !nachSlug["c"].Exists {
|
|
t.Error("vorhandene Einträge sollten Exists = true tragen")
|
|
}
|
|
fehlt, ok := nachSlug["fehlt"]
|
|
if !ok {
|
|
t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten")
|
|
}
|
|
if fehlt.Exists {
|
|
t.Error("Exists = true für einen Bezeichner ohne Eintrag")
|
|
}
|
|
|
|
if len(kanten) != 3 {
|
|
t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(kanten))
|
|
}
|
|
}
|
|
|
|
func TestLinkGraphIsProjectScoped(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
ws := testWorkspace()
|
|
|
|
a, _ := s.CreateProject(ctx, ws.ID, "a", "A", "")
|
|
b, _ := s.CreateProject(ctx, ws.ID, "b", "B", "")
|
|
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: a.ID, Slug: "geheim", Title: "Geheim",
|
|
Content: "nur in A", Source: SourceHuman})
|
|
s.PutEntry(ctx, EntryWrite{ProjectID: b.ID, Slug: "anderes", Title: "Anderes",
|
|
Content: "nur in B", Source: SourceHuman})
|
|
|
|
knoten, _, err := s.LinkGraph(ctx, a.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(knoten) != 1 || knoten[0].Slug != "geheim" {
|
|
t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", knoten)
|
|
}
|
|
}
|