Gespeicherte Suchanfragen: Suchkriterien inklusive aktiver Filter benannt speichern und live wiederausführen. - savedsearch/store.go: Postgres-Store, Save (Upsert über tenant_slug/ user_id/name), List/Get streng auf Mandant+Benutzer beschränkt, Delete entfernt genau eine Zeile. Execute führt jede Ausführung LIVE gegen search.Client aus, kein eingefrorener Snapshot. - search/facets.go: kleinste nötige Erweiterung — Client.SearchWithFilters (gemeinsame buildFilteredMust-Hilfsfunktion mit Facets extrahiert) liefert tatsächlich gefilterte Treffer statt nur Zählungen, sonst gäbe es keinen echten Weg, gespeicherte Filter beim Wiederausführen anzuwenden. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-08-PRUEFPROTOKOLL.md): 1. TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly: 2 kombinierte Filter liefern real genau das eine passende Dokument. 2. TestList_UserSeesNoOtherTenantsSavedSearches: Mandant B sieht real keine gespeicherten Suchen von Mandant A. 3. TestDelete_RemovesOnlyThatSavedSearch: Löschen entfernt real nur die eine gespeicherte Suche, die andere bleibt unverändert. Kein Umbau: Search/Index/Delete-Verhalten sonst unverändert, dedup/indexworker/storage/crypto/encstorage unverändert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
203 lines
6.3 KiB
Go
203 lines
6.3 KiB
Go
// Integrationstest (SRC-08): echte Postgres- UND Manticore-Instanz,
|
|
// folgt derselben TEST_*-Env-Konvention wie mail/internal/search.
|
|
package savedsearch
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
|
)
|
|
|
|
func setupStore(t *testing.T) *Store {
|
|
t.Helper()
|
|
dsn := os.Getenv("TEST_TENANT_DSN")
|
|
if dsn == "" {
|
|
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
|
}
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, dsn)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
t.Cleanup(func() { pool.Close() })
|
|
|
|
store := NewStore(pool)
|
|
if err := store.EnsureSchema(ctx); err != nil {
|
|
t.Fatalf("schema: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
_, _ = pool.Exec(context.Background(), `DELETE FROM mail_saved_searches WHERE tenant_slug LIKE 'mandant-src08-%'`)
|
|
})
|
|
return store
|
|
}
|
|
|
|
func setupSearchClient(t *testing.T) *search.Client {
|
|
t.Helper()
|
|
baseURL := os.Getenv("TEST_MANTICORE_URL")
|
|
if baseURL == "" {
|
|
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
|
|
}
|
|
client := search.NewClient(baseURL)
|
|
if err := client.EnsureSchema(context.Background()); err != nil {
|
|
t.Fatalf("search-schema: %v", err)
|
|
}
|
|
return client
|
|
}
|
|
|
|
// TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly ist die
|
|
// geforderte Pflichtprüfung 1: gespeicherte Suche mit mehreren Filtern
|
|
// wird korrekt reproduziert.
|
|
func TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly(t *testing.T) {
|
|
store := setupStore(t)
|
|
searchClient := setupSearchClient(t)
|
|
ctx := context.Background()
|
|
tenant := "mandant-src08-filter"
|
|
|
|
docs := []search.Document{
|
|
{MessageID: "msg-sv-treffer", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "inbox"},
|
|
{MessageID: "msg-sv-falscher-sender", Subject: "Quartalsbericht", Sender: "bob@example.com", Mailbox: "inbox"},
|
|
{MessageID: "msg-sv-falsche-mailbox", Subject: "Quartalsbericht", Sender: "alice@example.com", Mailbox: "archiv"},
|
|
}
|
|
for _, d := range docs {
|
|
d.TenantSlug = tenant
|
|
d.ID = search.DocumentID(tenant, d.MessageID)
|
|
if err := searchClient.Index(ctx, d); err != nil {
|
|
t.Fatalf("index %s: %v", d.MessageID, err)
|
|
}
|
|
}
|
|
|
|
filters := []search.FacetFilter{
|
|
{Field: search.FieldSender, Value: "alice@example.com"},
|
|
{Field: search.FieldMailbox, Value: "inbox"},
|
|
}
|
|
id, err := store.Save(ctx, tenant, "user-1", "Alice Inbox Quartalsbericht", "Quartalsbericht", filters)
|
|
if err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
|
|
saved, err := store.Get(ctx, tenant, "user-1", id)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
|
|
results, err := Execute(ctx, searchClient, saved)
|
|
if err != nil {
|
|
t.Fatalf("execute: %v", err)
|
|
}
|
|
if len(results) != 1 || results[0].MessageID != "msg-sv-treffer" {
|
|
t.Fatalf("erwartete genau msg-sv-treffer, habe: %+v", results)
|
|
}
|
|
}
|
|
|
|
// TestExecute_ReturnsCurrentResultsNotFrozenSnapshot deckt
|
|
// Akzeptanzkriterium 2 ab: aktuelle Treffer, kein eingefrorener Snapshot.
|
|
func TestExecute_ReturnsCurrentResultsNotFrozenSnapshot(t *testing.T) {
|
|
store := setupStore(t)
|
|
searchClient := setupSearchClient(t)
|
|
ctx := context.Background()
|
|
tenant := "mandant-src08-live"
|
|
|
|
docID := search.DocumentID(tenant, "msg-live-1")
|
|
// Verteidigung gegen Testreste eines vorherigen Laufs (Manticore wird
|
|
// zwischen Testläufen nicht automatisch geleert, anders als Postgres
|
|
// über t.Cleanup) — Ausgangszustand muss real leer sein, damit der
|
|
// Test die Aussage "kein eingefrorener Snapshot" beweiskräftig prüft.
|
|
_ = searchClient.Delete(ctx, docID)
|
|
t.Cleanup(func() { _ = searchClient.Delete(context.Background(), docID) })
|
|
|
|
id, err := store.Save(ctx, tenant, "user-1", "Alles zu Zylotharion", "Zylotharion", nil)
|
|
if err != nil {
|
|
t.Fatalf("save: %v", err)
|
|
}
|
|
saved, err := store.Get(ctx, tenant, "user-1", id)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
|
|
before, err := Execute(ctx, searchClient, saved)
|
|
if err != nil {
|
|
t.Fatalf("execute (vorher): %v", err)
|
|
}
|
|
if len(before) != 0 {
|
|
t.Fatalf("erwartete 0 treffer vor indexierung, habe %d", len(before))
|
|
}
|
|
|
|
if err := searchClient.Index(ctx, search.Document{
|
|
ID: docID,
|
|
TenantSlug: tenant,
|
|
MessageID: "msg-live-1",
|
|
Subject: "Neuigkeiten zu Zylotharion",
|
|
}); err != nil {
|
|
t.Fatalf("index: %v", err)
|
|
}
|
|
|
|
after, err := Execute(ctx, searchClient, saved)
|
|
if err != nil {
|
|
t.Fatalf("execute (nachher): %v", err)
|
|
}
|
|
if len(after) != 1 {
|
|
t.Fatalf("erwartete 1 aktuellen treffer nach neuer indexierung (kein eingefrorener snapshot), habe %d", len(after))
|
|
}
|
|
}
|
|
|
|
// TestList_UserSeesNoOtherTenantsSavedSearches ist die geforderte
|
|
// Pflichtprüfung 2: Benutzer sieht keine gespeicherten Suchen anderer
|
|
// Mandanten.
|
|
func TestList_UserSeesNoOtherTenantsSavedSearches(t *testing.T) {
|
|
store := setupStore(t)
|
|
ctx := context.Background()
|
|
tenantA := "mandant-src08-iso-a"
|
|
tenantB := "mandant-src08-iso-b"
|
|
|
|
if _, err := store.Save(ctx, tenantA, "user-1", "Suche A", "irgendwas", nil); err != nil {
|
|
t.Fatalf("save mandant a: %v", err)
|
|
}
|
|
if _, err := store.Save(ctx, tenantB, "user-1", "Suche B", "irgendwas", nil); err != nil {
|
|
t.Fatalf("save mandant b: %v", err)
|
|
}
|
|
|
|
listB, err := store.List(ctx, tenantB, "user-1")
|
|
if err != nil {
|
|
t.Fatalf("list mandant b: %v", err)
|
|
}
|
|
if len(listB) != 1 || listB[0].Name != "Suche B" {
|
|
t.Fatalf("mandant b sieht fremde/fehlende suchen: %+v", listB)
|
|
}
|
|
}
|
|
|
|
// TestDelete_RemovesOnlyThatSavedSearch ist die geforderte Pflichtprüfung
|
|
// 3: Löschen einer gespeicherten Suche entfernt nur diese.
|
|
func TestDelete_RemovesOnlyThatSavedSearch(t *testing.T) {
|
|
store := setupStore(t)
|
|
ctx := context.Background()
|
|
tenant := "mandant-src08-delete"
|
|
|
|
idA, err := store.Save(ctx, tenant, "user-1", "Suche A", "a", nil)
|
|
if err != nil {
|
|
t.Fatalf("save a: %v", err)
|
|
}
|
|
idB, err := store.Save(ctx, tenant, "user-1", "Suche B", "b", nil)
|
|
if err != nil {
|
|
t.Fatalf("save b: %v", err)
|
|
}
|
|
|
|
if err := store.Delete(ctx, tenant, "user-1", idA); err != nil {
|
|
t.Fatalf("delete a: %v", err)
|
|
}
|
|
|
|
if _, err := store.Get(ctx, tenant, "user-1", idA); err != ErrNotFound {
|
|
t.Fatalf("erwartete ErrNotFound für gelöschte suche a, habe: %v", err)
|
|
}
|
|
stillThere, err := store.Get(ctx, tenant, "user-1", idB)
|
|
if err != nil {
|
|
t.Fatalf("suche b sollte weiterhin existieren: %v", err)
|
|
}
|
|
if stillThere.Name != "Suche B" {
|
|
t.Fatalf("unerwarteter inhalt für suche b: %+v", stillThere)
|
|
}
|
|
}
|