munin/internal/store/usage_test.go
2026-10-19 10:13:56 +02:00

240 lines
8.8 KiB
Go

package store
import (
"context"
"strings"
"testing"
"time"
"unicode/utf8"
)
// TestUsageSummariesCountsWrites: Neuanlagen, Aktualisierungen und
// Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und
// nur innerhalb des Zeitfensters.
func TestUsageSummariesCountsWrites(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
log := func(source, action, detail string) {
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: action, Source: source, Detail: detail})
}
log(SourceMCP, ActionEntryWrite, DetailEntryCreated)
log(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
log(SourceMCP, ActionEntryWrite, DetailEntryUpdated)
log(SourceMCP, ActionEntryDelete, "in den Papierkorb")
log(SourceWeb, ActionEntryWrite, DetailEntryCreated)
log(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung
log(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso
log(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung
log(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht
// Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen.
old := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
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 (?, ?, ?, '', 'x', ?, '', ?, '', ?)`,
NewID(), old, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil {
t.Fatal(err)
}
all, err := s.UsageSummaries(ctx, "", time.Now())
if err != nil {
t.Fatal(err)
}
u := all[p.ID]
if (u.Agent != WriteCounts{Created: 1, Updated: 2, Deleted: 1}) {
t.Errorf("Agent = %+v", u.Agent)
}
if (u.Human != WriteCounts{Created: 1, Updated: 2, Unknown: 1}) {
t.Errorf("Mensch = %+v", u.Human)
}
// 4 Aktualisierungen von 6 bekannten Schreibvorgängen = 67 %.
if got := u.UpdatePercent(); got != 67 {
t.Errorf("UpdatePercent = %d, erwartet 67", got)
}
// Eingeschränkt auf das Projekt kommt dasselbe heraus.
single, _ := s.UsageSummaries(ctx, p.ID, time.Now())
if single[p.ID] != u {
t.Errorf("mit Projektfilter: %+v, ohne: %+v", single[p.ID], u)
}
}
// TestUsageSummariesInventory: Einträge, kuratierte und solche ohne Schlagwort —
// ohne Papierkorb.
func TestUsageSummariesInventory(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{"x"}, Curated: true, Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Source: SourceAgent})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "c", Title: "C", Source: SourceAgent})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Curated: true, Source: SourceAgent})
s.MoveEntryToTrash(ctx, p.ID, "weg", "tom")
all, _ := s.UsageSummaries(ctx, "", time.Now())
u := all[p.ID]
if u.Entries != 3 || u.Curated != 1 || u.Untagged != 2 {
t.Errorf("Bestand = %d Einträge, %d kuratiert, %d ohne Schlagwort; erwartet 3, 1, 2",
u.Entries, u.Curated, u.Untagged)
}
if u.CuratedPercent() != 33 {
t.Errorf("CuratedPercent = %d, erwartet 33", u.CuratedPercent())
}
if u.LastUpdatedAt.IsZero() {
t.Error("LastUpdatedAt fehlt")
}
// Keine Schreibvorgänge im Protokoll → kein sinnvoller Anteil.
if u.UpdatePercent() != -1 {
t.Errorf("UpdatePercent ohne Schreibvorgänge = %d, erwartet -1", u.UpdatePercent())
}
without, _ := s.UntaggedEntries(ctx, p.ID, 10)
if len(without) != 2 {
t.Errorf("UntaggedEntries = %v, erwartet b und c", without)
}
}
// TestMissingLinks: Verweise ins Leere, meistgenannte zuerst. Verweise aus
// dem Papierkorb zählen nicht, Verweise auf gelöschte Einträge schon.
func TestMissingLinks(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: "[[fehlt]] [[vorhanden]] [[geloescht]]", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "[[fehlt]]", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "vorhanden", Title: "V", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "geloescht", Title: "G", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "im-papierkorb", Title: "P", Content: "[[nur-von-dort]]", Source: SourceHuman})
s.MoveEntryToTrash(ctx, p.ID, "geloescht", "tom")
s.MoveEntryToTrash(ctx, p.ID, "im-papierkorb", "tom")
list, err := s.MissingLinks(ctx, p.ID)
if err != nil {
t.Fatal(err)
}
want := []MissingLink{{"fehlt", 2}, {"geloescht", 1}}
if len(list) != len(want) {
t.Fatalf("MissingLinks = %v, erwartet %v", list, want)
}
for i := range want {
if list[i] != want[i] {
t.Errorf("[%d] = %v, erwartet %v", i, list[i], want[i])
}
}
}
func TestRecentlyUsedKeys(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
used, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write")
s.CreateAPIKey(ctx, p.ID, "nie", "hash-2", "write")
gone, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write")
now := NowString()
s.write.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, now, used.ID, gone.ID)
s.RevokeAPIKey(ctx, gone.ID)
keys, err := s.RecentlyUsedKeys(ctx, p.ID, 5)
if err != nil {
t.Fatal(err)
}
if len(keys) != 1 || keys[0].Name != "benutzt" {
t.Errorf("RecentlyUsedKeys = %v, erwartet nur „benutzt“", keys)
}
}
// TestCuratedOverrideDetailTruncates prüft, dass eine ausufernde Begründung
// gekürzt wird — in Zeichen, nicht in Bytes, damit kein Umlaut zerbricht.
func TestCuratedOverrideDetailTruncates(t *testing.T) {
long := strings.Repeat("ü", maxJustification+50)
got := CuratedOverrideDetail(long)
if !strings.HasPrefix(got, DetailCuratedOverridden+": ") {
t.Errorf("Anfang fehlt: %q", got[:40])
}
if !utf8.ValidString(got) {
t.Error("die Kürzung hat ein Zeichen zerbrochen")
}
if n := utf8.RuneCountInString(got); n > len(DetailCuratedOverridden)+2+maxJustification+1 {
t.Errorf("Detail ist %d Zeichen lang, nicht gekürzt", n)
}
}
// TestCuratedOverridesLatestEvent prüft, dass bei mehreren Änderungen an
// einem Eintrag Zugang und Begründung der JÜNGSTEN Änderung erscheinen — und
// der Eintrag nur einmal.
//
// Früher hing das an einer Eigenheit von SQLite (MAX() mit Spalten ohne
// Aggregat), die PostgreSQL nicht kennt. Die Zeitstempel sind hier fest
// gesetzt, damit die Reihenfolge eindeutig ist.
func TestCuratedOverridesLatestEvent(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "vpn", Title: "VPN", Content: "x", Source: SourceAgent}); err != nil {
t.Fatal(err)
}
now := time.Now().UTC()
for _, v := range []struct {
age time.Duration
key, reason string
}{
{2 * time.Hour, "agent-alt", "alter Grund"},
{1 * time.Minute, "agent-neu", "neuer Grund"},
{1 * time.Hour, "agent-mitte", "mittlerer Grund"},
} {
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 (?, ?, ?, '', ?, ?, 'vpn', ?, '', ?)`,
NewID(), now.Add(-v.age).Format(time.RFC3339Nano), p.ID, v.key,
ActionEntryWrite, SourceMCP, CuratedOverrideDetail(v.reason)); err != nil {
t.Fatal(err)
}
}
list, err := s.CuratedOverrides(ctx, p.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 {
t.Fatalf("%d Zeilen, erwartet 1 (je Eintrag nur der jüngste Vorgang): %+v", len(list), list)
}
if list[0].Actor != "agent-neu" || list[0].Reason != "neuer Grund" {
t.Errorf("Zeile = %+v, erwartet agent-neu mit „neuer Grund“", list[0])
}
}
// TestCuratedOverridesWithoutDeleted prüft, dass Einträge im Papierkorb nicht
// zur Prüfung erscheinen — es gibt dort nichts mehr zu prüfen.
func TestCuratedOverridesWithoutDeleted(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Content: "x", Source: SourceAgent}); err != nil {
t.Fatal(err)
}
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryWrite,
EntrySlug: "weg", Source: SourceMCP, Detail: CuratedOverrideDetail("Grund")})
if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 1 {
t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(list))
}
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "mensch"); err != nil {
t.Fatal(err)
}
if list, _ := s.CuratedOverrides(ctx, p.ID, 10); len(list) != 0 {
t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", list)
}
}