1272 lines
42 KiB
Go
1272 lines
42 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.
|
|
//
|
|
// Mit MUNIN_TEST_PG läuft der Test stattdessen gegen PostgreSQL, in einem
|
|
// eigenen Schema (siehe postgres_test.go).
|
|
func testStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
if pgTestEnabled() {
|
|
return testPostgresStore(t)
|
|
}
|
|
|
|
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) {
|
|
filePath := filepath.Join(t.TempDir(), "test.db")
|
|
|
|
s1, err := Open(filePath)
|
|
if err != nil {
|
|
t.Fatalf("erster Start: %v", err)
|
|
}
|
|
p := testProject(t, s1)
|
|
s1.Close()
|
|
|
|
s2, err := Open(filePath)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// TestMigrationExistingData prüft, dass eine mit Fassung 001 angelegte
|
|
// Datenbank sauber auf 002 gehoben wird — der Fall bei jedem Update.
|
|
func TestMigrationExistingData(t *testing.T) {
|
|
filePath := filepath.Join(t.TempDir(), "alt.db")
|
|
|
|
s, err := Open(filePath)
|
|
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(filePath)
|
|
if err != nil {
|
|
t.Fatalf("Migration fehlgeschlagen: %v", err)
|
|
}
|
|
defer s2.Close()
|
|
|
|
// project_id muss in beiden Nebentabellen nachgetragen worden sein.
|
|
var emptyTags, emptyLinks int
|
|
s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_tags WHERE project_id = ''`).Scan(&emptyTags)
|
|
s2.read.QueryRowContext(ctx, `SELECT COUNT(*) FROM entry_links WHERE project_id = ''`).Scan(&emptyLinks)
|
|
if emptyTags != 0 || emptyLinks != 0 {
|
|
t.Errorf("nicht nachgetragen: %d Schlagworte, %d Verweise ohne Projekt", emptyTags, emptyLinks)
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// TestConcurrentWrites 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 TestConcurrentWrites(t *testing.T) {
|
|
s := testStore(t)
|
|
p := testProject(t, s)
|
|
ctx := context.Background()
|
|
|
|
const agents, perAgent = 8, 40
|
|
|
|
var wg sync.WaitGroup
|
|
var mu sync.Mutex
|
|
var errs []error
|
|
|
|
for a := 0; a < agents; a++ {
|
|
wg.Add(1)
|
|
go func(a int) {
|
|
defer wg.Done()
|
|
for i := 0; i < perAgent; 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()
|
|
errs = append(errs, err)
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}(a)
|
|
}
|
|
wg.Wait()
|
|
|
|
if len(errs) > 0 {
|
|
t.Errorf("%d von %d Schreibvorgängen fehlgeschlagen, erster: %v",
|
|
len(errs), agents*perAgent, errs[0])
|
|
}
|
|
|
|
n, err := s.CountEntries(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n != agents*perAgent {
|
|
t.Errorf("%d Einträge angelegt, erwartet %d", n, agents*perAgent)
|
|
}
|
|
}
|
|
|
|
// TestConcurrentReadAndWrite prüft, dass Lesezugriffe nicht hinter
|
|
// Schreibvorgängen warten müssen — der Grund für den getrennten Lesepool.
|
|
func TestConcurrentReadAndWrite(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 readErr, writeErr 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(&writeErr, 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(&readErr, 1)
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
|
|
if readErr > 0 || writeErr > 0 {
|
|
t.Errorf("%d Lesefehler, %d Schreibfehler bei gleichzeitigem Zugriff", readErr, writeErr)
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
|
|
read, err := s.ProjectByID(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if read.Name != "Neuer Name" || read.Description != "Neue Beschreibung" {
|
|
t.Errorf("Name/Beschreibung = %q/%q", read.Name, read.Description)
|
|
}
|
|
if read.WriteLimitHourly != 250 {
|
|
t.Errorf("WriteLimitHourly = %d, erwartet 250", read.WriteLimitHourly)
|
|
}
|
|
|
|
// Der Bezeichner bleibt fest — er steckt in URLs und MCP-Konfigurationen.
|
|
if read.Slug != p.Slug {
|
|
t.Errorf("Slug = %q, erwartet %q — der Bezeichner darf sich nicht ändern", read.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)
|
|
}
|
|
read, _ = s.ProjectByID(ctx, p.ID)
|
|
if read.WriteLimitHourly != 0 {
|
|
t.Errorf("WriteLimitHourly = %d nach dem Zurücksetzen, erwartet 0", read.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)
|
|
}
|
|
|
|
resolved, err := s.ResolveKeyHash(ctx, "hash-abc")
|
|
if err != nil {
|
|
t.Fatalf("gültiger Key wurde nicht aufgelöst: %v", err)
|
|
}
|
|
if resolved.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 resolved.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)
|
|
}
|
|
}
|
|
|
|
// TestAPIKeyUnused prüft, wann ein Key als „ungenutzt“ gilt (AUSBAUSTUFEN.md 4.7).
|
|
func TestAPIKeyUnused(t *testing.T) {
|
|
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
|
days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) }
|
|
|
|
cases := []struct {
|
|
name string
|
|
key APIKey
|
|
want bool
|
|
}{
|
|
{"gestern benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(1)}, false},
|
|
{"vor 91 Tagen benutzt", APIKey{CreatedAt: days(400), LastUsedAt: days(91)}, true},
|
|
{"genau 90 Tage", APIKey{CreatedAt: days(400), LastUsedAt: days(90)}, false},
|
|
{"nie benutzt, frisch angelegt", APIKey{CreatedAt: days(3)}, false},
|
|
{"nie benutzt, lange angelegt", APIKey{CreatedAt: days(120)}, true},
|
|
{"zurückgezogen", APIKey{CreatedAt: days(400), LastUsedAt: days(200), RevokedAt: days(100)}, false},
|
|
}
|
|
|
|
for _, f := range cases {
|
|
t.Run(f.name, func(t *testing.T) {
|
|
if got := f.key.Unused(now); got != f.want {
|
|
t.Errorf("Unused = %v, erwartet %v", got, f.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceAdminUnused: Workspace-Admins folgen derselben Regel wie Keys.
|
|
func TestWorkspaceAdminUnused(t *testing.T) {
|
|
now := time.Date(2026, 9, 30, 12, 0, 0, 0, time.UTC)
|
|
days := func(n int) time.Time { return now.Add(-time.Duration(n) * 24 * time.Hour) }
|
|
|
|
cases := []struct {
|
|
name string
|
|
admin WorkspaceAdmin
|
|
want bool
|
|
}{
|
|
{"gestern benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(1)}, false},
|
|
{"vor 91 Tagen benutzt", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(91)}, true},
|
|
{"nie benutzt, frisch angelegt", WorkspaceAdmin{CreatedAt: days(3)}, false},
|
|
{"nie benutzt, lange angelegt", WorkspaceAdmin{CreatedAt: days(120)}, true},
|
|
{"zurückgezogen", WorkspaceAdmin{CreatedAt: days(400), LastUsedAt: days(200), RevokedAt: days(100)}, false},
|
|
}
|
|
|
|
for _, f := range cases {
|
|
t.Run(f.name, func(t *testing.T) {
|
|
if got := f.admin.Unused(now); got != f.want {
|
|
t.Errorf("Unused = %v, erwartet %v", got, f.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
first, 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 first.Version != 1 {
|
|
t.Errorf("Version = %d, erwartet 1", first.Version)
|
|
}
|
|
// Schlagworte werden vereinheitlicht: klein, ohne Dubletten, sortiert.
|
|
if len(first.Tags) != 2 || first.Tags[0] != "deploy" || first.Tags[1] != "ops" {
|
|
t.Errorf("Tags = %v, erwartet [deploy ops]", first.Tags)
|
|
}
|
|
|
|
// Derselbe Slug aktualisiert statt anzulegen — das ist der Kern der
|
|
// Dublettenvermeidung.
|
|
second, 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 second.Version != 2 {
|
|
t.Errorf("Version = %d, erwartet 2", second.Version)
|
|
}
|
|
if second.ID != first.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(second.Tags) != 0 {
|
|
t.Errorf("Tags = %v, erwartet leer", second.Tags)
|
|
}
|
|
}
|
|
|
|
// TestPutEntryOptimisticLocking prüft den Schutz vor stillem Überschreiben.
|
|
func TestPutEntryOptimisticLocking(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
base := EntryWrite{ProjectID: p.ID, Slug: "geteilt", Title: "T", Source: SourceAgent}
|
|
|
|
e, err := s.PutEntry(ctx, base)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Agent A schreibt auf Basis von Version 1 — geht durch.
|
|
a := base
|
|
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 := base
|
|
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})
|
|
|
|
filtered, err := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "ops"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(filtered) != 2 {
|
|
t.Errorf("%d Treffer, erwartet 2", len(filtered))
|
|
}
|
|
|
|
// Groß-/Kleinschreibung darf keine Rolle spielen.
|
|
capitalized, _ := s.ListEntries(ctx, p.ID, EntryFilter{Tag: "OPS"})
|
|
if len(capitalized) != 2 {
|
|
t.Errorf("%d Treffer bei Großschreibung, erwartet 2", len(capitalized))
|
|
}
|
|
}
|
|
|
|
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,
|
|
|
|
// In Code zählt nichts (siehe refsOutsideCode).
|
|
"Verweis: `[[beispiel]]`, echt: [[echt]]": {"echt"},
|
|
"doppelte Backticks: ``a `[[x]]` b`` und [[y]]": {"y"},
|
|
"```\n[[im-block]]\n```\n[[danach]]": {"danach"},
|
|
"~~~go\n[[tilde]]\n~~~\n": nil,
|
|
" ```\n[[eingerueckter-zaun]]\n ```": nil,
|
|
"````\n```\n[[innen]]\n````\n[[aussen]]": {"aussen"},
|
|
"```\n[[offen-bis-zum-ende]]": nil,
|
|
"ein ` allein, dann [[zaehlt]]": {"zaehlt"},
|
|
" [[eingerueckt-vier]] zählt (wie Listenzeilen)": {"eingerueckt-vier"},
|
|
}
|
|
|
|
for input, want := range cases {
|
|
got := ExtractLinks(input)
|
|
if len(got) != len(want) {
|
|
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != want[i] {
|
|
t.Errorf("ExtractLinks(%q) = %v, erwartet %v", input, got, want)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestReplaceLinksLeavesCodeAlone prüft das Gegenstück beim Anzeigen: Ein
|
|
// Verweis in Code bleibt Text, ein echter wird ersetzt.
|
|
func TestReplaceLinksLeavesCodeAlone(t *testing.T) {
|
|
in := "So verweist man: `[[bezeichner]]`. Siehe [[deployment]].\n```\n[[auch-nicht]]\n```\n"
|
|
expected := "So verweist man: `[[bezeichner]]`. Siehe <deployment>.\n```\n[[auch-nicht]]\n```\n"
|
|
got := ReplaceLinks(in, func(slug string) string { return "<" + slug + ">" })
|
|
if got != expected {
|
|
t.Errorf("\nist: %q\nsoll: %q", got, expected)
|
|
}
|
|
}
|
|
|
|
// 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})
|
|
|
|
events, err := s.ListAudit(ctx, AuditFilter{ProjectID: p.ID})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(events) != 2 {
|
|
t.Fatalf("%d Ereignisse, erwartet 2", len(events))
|
|
}
|
|
// Neueste zuerst.
|
|
if events[0].Action != ActionEntryDelete {
|
|
t.Errorf("erstes Ereignis = %q, erwartet %q", events[0].Action, ActionEntryDelete)
|
|
}
|
|
if events[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.
|
|
future := time.Now().UTC().Add(time.Hour).Format(timeFormat)
|
|
n, err := s.PruneAudit(ctx, future)
|
|
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, want := range cases {
|
|
if got := Slugify(input); got != want {
|
|
t.Errorf("Slugify(%q) = %q, erwartet %q", input, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNewIDIsSortableAndUnique(t *testing.T) {
|
|
seen := make(map[string]bool)
|
|
var before string
|
|
|
|
for i := 0; i < 200; i++ {
|
|
id := NewID()
|
|
if seen[id] {
|
|
t.Fatalf("doppelte ID: %s", id)
|
|
}
|
|
seen[id] = true
|
|
|
|
// Der Zeitanteil steht vorne, IDs sind also grob chronologisch sortierbar.
|
|
if before != "" && id < before {
|
|
earlierTime, _, _ := strings.Cut(before, "-")
|
|
nowTime, _, _ := strings.Cut(id, "-")
|
|
if nowTime < earlierTime {
|
|
t.Errorf("IDs sind nicht chronologisch: %s vor %s", before, id)
|
|
}
|
|
}
|
|
before = 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})
|
|
|
|
changed, err := s.BulkUpdateTags(ctx, p.ID, []string{a.ID, b.ID}, []string{"neu"}, []string{"alt"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(changed) != 2 {
|
|
t.Fatalf("%d geänderte Einträge zurückgegeben, erwartet 2", len(changed))
|
|
}
|
|
|
|
freshA, _ := s.EntryBySlug(ctx, p.ID, "a")
|
|
if !stringSlicesEqual(freshA.Tags, []string{"neu"}) {
|
|
t.Errorf("Tags von A = %v, erwartet [neu]", freshA.Tags)
|
|
}
|
|
if freshA.Version != 2 {
|
|
t.Errorf("Version von A = %d, erwartet 2 (Sammelbearbeitung zählt als Änderung)", freshA.Version)
|
|
}
|
|
|
|
freshB, _ := s.EntryBySlug(ctx, p.ID, "b")
|
|
if !stringSlicesEqual(freshB.Tags, []string{"bleibt", "neu"}) {
|
|
t.Errorf("Tags von B = %v, erwartet [bleibt neu]", freshB.Tags)
|
|
}
|
|
|
|
unchangedC, _ := s.EntryBySlug(ctx, p.ID, "c")
|
|
if !stringSlicesEqual(unchangedC.Tags, []string{"alt"}) {
|
|
t.Errorf("Tags von C = %v — C war nicht ausgewählt und hätte sich nicht ändern dürfen", unchangedC.Tags)
|
|
}
|
|
if unchangedC.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", "")
|
|
|
|
foreign, _ := 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.
|
|
changed, err := s.BulkUpdateTags(ctx, a.ID, []string{foreign.ID}, []string{"neu"}, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(changed) != 0 {
|
|
t.Errorf("%d Einträge geändert, erwartet 0 — die ID gehörte zu einem fremden Projekt", len(changed))
|
|
}
|
|
|
|
unchanged, _ := s.EntryBySlug(ctx, b.ID, "fremd")
|
|
if !stringSlicesEqual(unchanged.Tags, []string{"x"}) {
|
|
t.Errorf("Tags = %v — der fremde Eintrag wurde trotzdem verändert", unchanged.Tags)
|
|
}
|
|
}
|
|
|
|
// ── Änderungshistorie ────────────────────────────────────────────────────────
|
|
|
|
func TestPutEntryRecordsHistory(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
first, 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.
|
|
history, err := s.EntryVersions(ctx, first.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(history) != 0 {
|
|
t.Errorf("%d Fassungen nach dem Anlegen, erwartet 0", len(history))
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
history, err = s.EntryVersions(ctx, first.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(history) != 2 {
|
|
t.Fatalf("%d Fassungen in der Historie, erwartet 2", len(history))
|
|
}
|
|
// Neueste zuerst.
|
|
if history[0].Version != 2 || history[0].Title != "Zweite Fassung" {
|
|
t.Errorf("historie[0] = Version %d %q, erwartet Version 2 \"Zweite Fassung\"", history[0].Version, history[0].Title)
|
|
}
|
|
if history[1].Version != 1 || history[1].Title != "Erste Fassung" {
|
|
t.Errorf("historie[1] = Version %d %q, erwartet Version 1 \"Erste Fassung\"", history[1].Version, history[1].Title)
|
|
}
|
|
if history[1].Content != "Inhalt 1" || !stringSlicesEqual(history[1].Tags, []string{"a"}) {
|
|
t.Errorf("historie[1] Inhalt/Tags = %q/%v, erwartet \"Inhalt 1\"/[a]", history[1].Content, history[1].Tags)
|
|
}
|
|
|
|
version1, err := s.EntryVersionByNumber(ctx, first.ID, 1)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if version1.Content != "Inhalt 1" {
|
|
t.Errorf("EntryVersionByNumber(1).Content = %q, erwartet \"Inhalt 1\"", version1.Content)
|
|
}
|
|
|
|
if _, err := s.EntryVersionByNumber(ctx, first.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)
|
|
}
|
|
|
|
history, err := s.EntryVersions(ctx, e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(history) != 1 {
|
|
t.Fatalf("%d Fassungen, erwartet 1", len(history))
|
|
}
|
|
if !stringSlicesEqual(history[0].Tags, []string{"alt"}) {
|
|
t.Errorf("gesicherte Tags = %v, erwartet [alt] (der Stand vor der Sammelbearbeitung)", history[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 history, _ := s.EntryVersions(ctx, e.ID); len(history) != 1 {
|
|
t.Errorf("%d Fassungen im Papierkorb, erwartet 1 — der Verlauf muss erhalten bleiben", len(history))
|
|
}
|
|
|
|
if n, err := s.PurgeFromTrash(ctx, p.ID, []string{"e"}); err != nil || n != 1 {
|
|
t.Fatalf("PurgeFromTrash: %d, %v", n, err)
|
|
}
|
|
|
|
history, err := s.EntryVersions(ctx, e.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(history) != 0 {
|
|
t.Errorf("%d Fassungen nach dem Löschen des Eintrags, erwartet 0 (CASCADE)", len(history))
|
|
}
|
|
}
|
|
|
|
// ── 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})
|
|
|
|
nodes, edges, err := s.LinkGraph(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if len(nodes) != 4 {
|
|
t.Fatalf("%d Knoten, erwartet 4 (a, b, c, fehlt)", len(nodes))
|
|
}
|
|
|
|
toSlug := make(map[string]GraphNode, len(nodes))
|
|
for _, k := range nodes {
|
|
toSlug[k.Slug] = k
|
|
}
|
|
|
|
if !toSlug["a"].Exists || !toSlug["b"].Exists || !toSlug["c"].Exists {
|
|
t.Error("vorhandene Einträge sollten Exists = true tragen")
|
|
}
|
|
missing, ok := toSlug["fehlt"]
|
|
if !ok {
|
|
t.Fatal("Verweis auf einen nicht existierenden Bezeichner erzeugte keinen Knoten")
|
|
}
|
|
if missing.Exists {
|
|
t.Error("Exists = true für einen Bezeichner ohne Eintrag")
|
|
}
|
|
|
|
if len(edges) != 3 {
|
|
t.Fatalf("%d Kanten, erwartet 3 (a→b, a→fehlt, b→a)", len(edges))
|
|
}
|
|
}
|
|
|
|
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})
|
|
|
|
nodes, _, err := s.LinkGraph(ctx, a.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(nodes) != 1 || nodes[0].Slug != "geheim" {
|
|
t.Errorf("Knoten = %v — Projekt A sieht Einträge aus Projekt B", nodes)
|
|
}
|
|
}
|
|
|
|
// TestStaleEntriesFilterAndCount: ListEntries mit ChangedBefore und
|
|
// CountStaleEntries finden genau die Einträge, die seit über einem Jahr
|
|
// unverändert sind — und keine im Papierkorb.
|
|
func TestStaleEntriesFilterAndCount(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
now := time.Now()
|
|
|
|
old := now.Add(-400 * 24 * time.Hour)
|
|
for _, w := range []EntryWrite{
|
|
{Slug: "alt", Title: "Alt", CreatedAt: old, UpdatedAt: old},
|
|
{Slug: "alt-geloescht", Title: "Alt, gelöscht", CreatedAt: old, UpdatedAt: old},
|
|
{Slug: "frisch", Title: "Frisch"},
|
|
} {
|
|
w.ProjectID, w.Content, w.Source = p.ID, "x", SourceHuman
|
|
if _, err := s.PutEntry(ctx, w); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := s.MoveEntryToTrash(ctx, p.ID, "alt-geloescht", "test"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
list, err := s.ListEntries(ctx, p.ID, EntryFilter{ChangedBefore: StaleCutoff(now)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(list) != 1 || list[0].Slug != "alt" {
|
|
t.Errorf("gefiltert: %d Einträge, erwartet nur „alt“", len(list))
|
|
}
|
|
|
|
count, err := s.CountStaleEntries(ctx, p.ID, now)
|
|
if err != nil || count != 1 {
|
|
t.Errorf("CountStaleEntries = %d, %v; erwartet 1", count, err)
|
|
}
|
|
}
|
|
|
|
// TestAuditFilter: ListAudit schränkt nach Vorgang, Zugang und Zeitraum ein,
|
|
// AuditActors nennt die Zugänge.
|
|
func TestAuditFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
|
|
now := time.Now().UTC()
|
|
for _, z := range []struct {
|
|
age time.Duration
|
|
who, event string
|
|
}{
|
|
{48 * time.Hour, "anna", ActionEntryWrite},
|
|
{24 * time.Hour, "bernd", ActionEntryDelete},
|
|
{1 * time.Hour, "anna", ActionEntryDelete},
|
|
} {
|
|
if _, err := s.write.ExecContext(ctx,
|
|
`INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail)
|
|
VALUES (?, ?, ?, '', ?, ?, '', 'web', '', '')`,
|
|
NewID(), now.Add(-z.age).Format(time.RFC3339Nano), p.ID, z.who, z.event); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
count := func(f AuditFilter) int {
|
|
t.Helper()
|
|
f.ProjectID = p.ID
|
|
list, err := s.ListAudit(ctx, f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return len(list)
|
|
}
|
|
if n := count(AuditFilter{Action: ActionEntryDelete}); n != 2 {
|
|
t.Errorf("Vorgang: %d, erwartet 2", n)
|
|
}
|
|
if n := count(AuditFilter{Actor: "anna"}); n != 2 {
|
|
t.Errorf("Zugang: %d, erwartet 2", n)
|
|
}
|
|
if n := count(AuditFilter{Actor: "anna", Action: ActionEntryDelete}); n != 1 {
|
|
t.Errorf("Zugang und Vorgang: %d, erwartet 1", n)
|
|
}
|
|
if n := count(AuditFilter{Since: now.Add(-30 * time.Hour), Until: now.Add(-2 * time.Hour)}); n != 1 {
|
|
t.Errorf("Zeitraum: %d, erwartet 1 (bernd)", n)
|
|
}
|
|
|
|
keys, err := s.AuditActors(ctx, p.ID)
|
|
if err != nil || strings.Join(keys, ",") != "anna,bernd" {
|
|
t.Errorf("AuditActors = %v, %v; erwartet anna,bernd", keys, err)
|
|
}
|
|
}
|
|
|
|
// TestKeyExpiry: Ein abgelaufener Key gilt wie ein zurückgezogener — er lässt
|
|
// sich nicht mehr auflösen. Ein Ablauf in der Vergangenheit wird gar nicht
|
|
// erst angenommen.
|
|
func TestKeyExpiry(t *testing.T) {
|
|
s := testStore(t)
|
|
ctx := context.Background()
|
|
p := testProject(t, s)
|
|
now := time.Now()
|
|
|
|
if _, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "zu-spaet", "h0", "write", now.Add(-time.Hour)); err == nil {
|
|
t.Error("Key mit Ablauf in der Vergangenheit wurde angelegt")
|
|
}
|
|
|
|
k, err := s.CreateAPIKeyWithExpiry(ctx, p.ID, "auf-zeit", "h1", "write", now.Add(time.Hour))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.ResolveKeyHash(ctx, "h1"); err != nil {
|
|
t.Errorf("gültiger Key lässt sich nicht auflösen: %v", err)
|
|
}
|
|
read, _ := s.APIKeyByID(ctx, k.ID)
|
|
if read.ExpiresAt.IsZero() || !read.ExpiresSoon(now) || read.Expired(now) {
|
|
t.Errorf("Ablauf nicht gespeichert oder falsch bewertet: %+v", read)
|
|
}
|
|
|
|
// Ablauf in die Vergangenheit setzen — wie ein Key, dessen Tag vorbei ist.
|
|
if _, err := s.write.ExecContext(ctx, `UPDATE api_keys SET expires_at = ? WHERE id = ?`,
|
|
now.Add(-time.Minute).UTC().Format(time.RFC3339Nano), k.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.ResolveKeyHash(ctx, "h1"); !errors.Is(err, ErrNotFound) {
|
|
t.Errorf("abgelaufener Key: err = %v, erwartet ErrNotFound", err)
|
|
}
|
|
read, _ = s.APIKeyByID(ctx, k.ID)
|
|
if read.Valid(now) || read.Unused(now.Add(200*24*time.Hour)) {
|
|
t.Error("abgelaufener Key gilt als gültig oder als ungenutzt")
|
|
}
|
|
|
|
// Ohne Ablauf: wie bisher.
|
|
if _, err := s.CreateAPIKey(ctx, p.ID, "unbegrenzt", "h2", "read"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.ResolveKeyHash(ctx, "h2"); err != nil {
|
|
t.Errorf("Key ohne Ablauf: %v", err)
|
|
}
|
|
}
|