package store import ( "context" "strings" "testing" "time" "unicode/utf8" ) // TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und // Löschungen werden getrennt nach Mensch (Web) und Agent (MCP) gezählt — und // nur innerhalb des Zeitfensters. func TestUsageSummariesZaehltSchreibvorgaenge(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) protokoll := func(quelle, aktion, detail string) { s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "x", Action: aktion, Source: quelle, Detail: detail}) } protokoll(SourceMCP, ActionEntryWrite, DetailEntryCreated) protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) protokoll(SourceMCP, ActionEntryWrite, DetailEntryUpdated) protokoll(SourceMCP, ActionEntryDelete, "in den Papierkorb") protokoll(SourceWeb, ActionEntryWrite, DetailEntryCreated) protokoll(SourceWeb, ActionEntryWrite, EntryRestoreDetail(3)) // zählt als Aktualisierung protokoll(SourceWeb, ActionEntryWrite, "Sammelbearbeitung: 2 …") // ebenso protokoll(SourceWeb, ActionEntryWrite, "") // aus der Zeit vor der Unterscheidung protokoll(SourceWeb, ActionLoginSuccess, "") // zählt gar nicht // Ein Schreibvorgang außerhalb des Fensters darf nicht mitzählen. alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano) if _, err := s.schreiben.ExecContext(ctx, `INSERT INTO audit_events (id, ts, project_id, api_key_id, actor, action, entry_slug, source, ip, detail) VALUES (?, ?, ?, '', 'x', ?, '', ?, '', ?)`, NewID(), alt, p.ID, ActionEntryWrite, SourceMCP, DetailEntryCreated); err != nil { t.Fatal(err) } alle, err := s.UsageSummaries(ctx, "", time.Now()) if err != nil { t.Fatal(err) } u := alle[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. einzeln, _ := s.UsageSummaries(ctx, p.ID, time.Now()) if einzeln[p.ID] != u { t.Errorf("mit Projektfilter: %+v, ohne: %+v", einzeln[p.ID], u) } } // TestUsageSummariesBestand: Einträge, kuratierte und solche ohne Schlagwort — // ohne Papierkorb. func TestUsageSummariesBestand(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") alle, _ := s.UsageSummaries(ctx, "", time.Now()) u := alle[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()) } ohne, _ := s.UntaggedEntries(ctx, p.ID, 10) if len(ohne) != 2 { t.Errorf("UntaggedEntries = %v, erwartet b und c", ohne) } } // 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") liste, err := s.MissingLinks(ctx, p.ID) if err != nil { t.Fatal(err) } erwartet := []MissingLink{{"fehlt", 2}, {"geloescht", 1}} if len(liste) != len(erwartet) { t.Fatalf("MissingLinks = %v, erwartet %v", liste, erwartet) } for i := range erwartet { if liste[i] != erwartet[i] { t.Errorf("[%d] = %v, erwartet %v", i, liste[i], erwartet[i]) } } } func TestRecentlyUsedKeys(t *testing.T) { s := testStore(t) ctx := context.Background() p := testProject(t, s) benutzt, _ := s.CreateAPIKey(ctx, p.ID, "benutzt", "hash-1", "write") s.CreateAPIKey(ctx, p.ID, "nie", "hash-2", "write") weg, _ := s.CreateAPIKey(ctx, p.ID, "zurueckgezogen", "hash-3", "write") jetzt := NowString() s.schreiben.ExecContext(ctx, `UPDATE api_keys SET last_used_at = ? WHERE id IN (?, ?)`, jetzt, benutzt.ID, weg.ID) s.RevokeAPIKey(ctx, weg.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) } } // TestCuratedOverrideDetailKuerzt prüft, dass eine ausufernde Begründung // gekürzt wird — in Zeichen, nicht in Bytes, damit kein Umlaut zerbricht. func TestCuratedOverrideDetailKuerzt(t *testing.T) { lang := strings.Repeat("ü", maxBegruendung+50) got := CuratedOverrideDetail(lang) 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+maxBegruendung+1 { t.Errorf("Detail ist %d Zeichen lang, nicht gekürzt", n) } } // TestCuratedOverridesOhneGeloeschte prüft, dass Einträge im Papierkorb nicht // zur Prüfung erscheinen — es gibt dort nichts mehr zu prüfen. func TestCuratedOverridesOhneGeloeschte(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 liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 1 { t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(liste)) } if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "mensch"); err != nil { t.Fatal(err) } if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 0 { t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", liste) } }