SRC-08: gespeicherte-suchanfragen
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
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6b5cefc20f
commit
ff4d716b94
@@ -0,0 +1,59 @@
|
|||||||
|
# SRC-08 – Prüfprotokoll: Gespeicherte Suchanfragen
|
||||||
|
|
||||||
|
Voraussetzung SRC-03 (Fertig).
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `mail/internal/savedsearch/store.go` — `Store` (Postgres,
|
||||||
|
`mail_saved_searches`): `Save` (Upsert über `UNIQUE(tenant_slug,
|
||||||
|
user_id, name)`, Akzeptanzkriterium 1), `List`/`Get` streng auf
|
||||||
|
Mandant UND Benutzer beschränkt (Akzeptanzkriterium 3), `Delete`
|
||||||
|
löscht genau eine Zeile über `tenant_slug + user_id + id`.
|
||||||
|
„Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung
|
||||||
|
(Core-Board-Scope) ein vom Aufrufer mitgegebener opaker
|
||||||
|
`userID`-String — dieselbe Konvention wie der Tenant-Kontext in
|
||||||
|
`web/mail-search` (SRC-04).
|
||||||
|
- `Execute(ctx, client, saved)` führt die gespeicherte Suche LIVE gegen
|
||||||
|
`search.Client` aus — speichert selbst keine Treffer, jeder Aufruf
|
||||||
|
fragt Manticore neu ab (Akzeptanzkriterium 2).
|
||||||
|
- `mail/internal/search/facets.go` — kleinste nötige Erweiterung: neue
|
||||||
|
Methode `Client.SearchWithFilters` (gemeinsame `buildFilteredMust`-
|
||||||
|
Hilfsfunktion mit `Facets` extrahiert) liefert TATSÄCHLICH gefilterte
|
||||||
|
Treffer statt nur Facettenzählungen — ohne dies gäbe es keinen echten
|
||||||
|
Weg, gespeicherte Filter beim Wiederausführen anzuwenden.
|
||||||
|
- Kein Umbau: `Search`/`Facets`/`Index`/`Delete`-Verhalten sonst
|
||||||
|
unverändert, `mail/internal/dedup`/`indexworker`/`storage`/`crypto`/
|
||||||
|
`encstorage` unverändert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Test: gespeicherte Suche mit mehreren Filtern wird korrekt reproduziert | **bestanden** – `TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly`: 3 Dokumente, 2 Filter (Sender+Postfach) gespeichert, `Execute` liefert real genau das eine Dokument, das beide Filter erfüllt |
|
||||||
|
| 2 | Test: Benutzer sieht keine gespeicherten Suchen anderer Mandanten | **bestanden** – `TestList_UserSeesNoOtherTenantsSavedSearches`: zwei Mandanten mit je einer gespeicherten Suche, `List` bei Mandant B liefert real nur die eigene, nicht die von Mandant A |
|
||||||
|
| 3 | Test: Löschen einer gespeicherten Suche entfernt nur diese | **bestanden** – `TestDelete_RemovesOnlyThatSavedSearch`: zwei gespeicherte Suchen, eine gelöscht, `Get` liefert für die gelöschte real `ErrNotFound`, die andere bleibt real unverändert abrufbar |
|
||||||
|
|
||||||
|
Zusätzlich (Akzeptanzkriterium 2, kein eingefrorener Snapshot):
|
||||||
|
`TestExecute_ReturnsCurrentResultsNotFrozenSnapshot` — Ausführung vor
|
||||||
|
einer neuen Indexierung liefert real 0 Treffer, danach real 1 Treffer,
|
||||||
|
ohne dass die gespeicherte Suche selbst verändert wurde.
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
|
||||||
|
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
|
||||||
|
go test ./... -p 1 -> alle Pakete bestanden, inkl. internal/savedsearch (4 Tests, neu),
|
||||||
|
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate/
|
||||||
|
crypto/search
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. Trägt (gemeinsam mit ARC-08, SRC-02, SRC-04, SRC-05,
|
||||||
|
SRC-09) zu QA-03 bei — QA-03 bleibt weiterhin blockiert, bis auch
|
||||||
|
SRC-10 fertig ist.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS mail_saved_searches (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_slug TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
query_text TEXT NOT NULL DEFAULT '',
|
||||||
|
filters JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (tenant_slug, user_id, name)
|
||||||
|
)
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
// Package savedsearch implementiert SRC-08: benannte, wiederausführbare
|
||||||
|
// Suchanfragen inklusive aktiver Filter. Speichert ausschließlich die
|
||||||
|
// SUCHKRITERIEN (Suchtext + Filter), niemals Trefferlisten — Execute ruft
|
||||||
|
// bei jeder Ausführung real gegen mail/internal/search (SRC-01/SRC-03/
|
||||||
|
// SRC-05) auf, damit stets aktuelle Treffer geliefert werden, kein
|
||||||
|
// eingefrorener Snapshot (Akzeptanzkriterium 2). Kombiniert bewusst zwei
|
||||||
|
// bereits fertige, unveränderte Pakete (Postgres-Speicherung hier,
|
||||||
|
// Ausführung über search.Client) statt eines davon zu erweitern.
|
||||||
|
//
|
||||||
|
// "Benutzer" ist bis zu einer zentralen Session-/IAM-Anbindung (Core-
|
||||||
|
// Board-Scope, nicht Bestandteil dieser Kachel) ein vom Aufrufer
|
||||||
|
// mitgegebener opaker userID-String — dieselbe Konvention wie der
|
||||||
|
// Tenant-Kontext in web/mail-search (SRC-04).
|
||||||
|
package savedsearch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/mail/internal/search"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed migrations/0001_mail_saved_searches.sql
|
||||||
|
var schemaMigration string
|
||||||
|
|
||||||
|
// ErrNotFound wird geliefert, wenn keine gespeicherte Suche mit den
|
||||||
|
// angegebenen Bezugsdaten existiert.
|
||||||
|
var ErrNotFound = errors.New("savedsearch: gespeicherte suche nicht gefunden")
|
||||||
|
|
||||||
|
// SavedSearch ist eine benannte, wiederausführbare Suchanfrage.
|
||||||
|
type SavedSearch struct {
|
||||||
|
ID int64
|
||||||
|
TenantSlug string
|
||||||
|
UserID string
|
||||||
|
Name string
|
||||||
|
QueryText string
|
||||||
|
Filters []search.FacetFilter
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store persistiert gespeicherte Suchen in Postgres.
|
||||||
|
type Store struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore(pool *pgxpool.Pool) *Store {
|
||||||
|
return &Store{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureSchema legt die Tabelle an, falls sie noch nicht existiert —
|
||||||
|
// gleiches Muster wie mail/internal/dedup/indexworker (kein zentraler
|
||||||
|
// Migrationsläufer für Mandanten-Datenbanken im Mail-Modul vorhanden).
|
||||||
|
func (s *Store) EnsureSchema(ctx context.Context) error {
|
||||||
|
if _, err := s.pool.Exec(ctx, schemaMigration); err != nil {
|
||||||
|
return fmt.Errorf("savedsearch: schema anlegen: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save legt eine gespeicherte Suche unter name an oder aktualisiert sie,
|
||||||
|
// falls unter demselben Namen für denselben Benutzer/Mandanten bereits
|
||||||
|
// eine existiert (Akzeptanzkriterium 1).
|
||||||
|
func (s *Store) Save(ctx context.Context, tenantSlug, userID, name, queryText string, filters []search.FacetFilter) (int64, error) {
|
||||||
|
filtersJSON, err := json.Marshal(filters)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("savedsearch: filter serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var id int64
|
||||||
|
err = s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO mail_saved_searches (tenant_slug, user_id, name, query_text, filters)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
ON CONFLICT (tenant_slug, user_id, name) DO UPDATE
|
||||||
|
SET query_text = EXCLUDED.query_text, filters = EXCLUDED.filters, updated_at = now()
|
||||||
|
RETURNING id
|
||||||
|
`, tenantSlug, userID, name, queryText, filtersJSON).Scan(&id)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("savedsearch: speichern: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List liefert alle gespeicherten Suchen eines Benutzers innerhalb eines
|
||||||
|
// Mandanten (Akzeptanzkriterium 3: je Benutzer/Mandant getrennt sichtbar).
|
||||||
|
func (s *Store) List(ctx context.Context, tenantSlug, userID string) ([]SavedSearch, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, tenant_slug, user_id, name, query_text, filters
|
||||||
|
FROM mail_saved_searches
|
||||||
|
WHERE tenant_slug = $1 AND user_id = $2
|
||||||
|
ORDER BY name
|
||||||
|
`, tenantSlug, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("savedsearch: liste lesen: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var results []SavedSearch
|
||||||
|
for rows.Next() {
|
||||||
|
item, err := scanSavedSearch(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("savedsearch: liste iterieren: %w", err)
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get liefert eine einzelne gespeicherte Suche, streng auf Mandant und
|
||||||
|
// Benutzer beschränkt.
|
||||||
|
func (s *Store) Get(ctx context.Context, tenantSlug, userID string, id int64) (SavedSearch, error) {
|
||||||
|
row := s.pool.QueryRow(ctx, `
|
||||||
|
SELECT id, tenant_slug, user_id, name, query_text, filters
|
||||||
|
FROM mail_saved_searches
|
||||||
|
WHERE tenant_slug = $1 AND user_id = $2 AND id = $3
|
||||||
|
`, tenantSlug, userID, id)
|
||||||
|
item, err := scanSavedSearch(row)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return SavedSearch{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return SavedSearch{}, err
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete entfernt GENAU EINE gespeicherte Suche, streng auf Mandant und
|
||||||
|
// Benutzer beschränkt (Akzeptanzkriterium/Pflichtprüfung 3: Löschen
|
||||||
|
// entfernt nur diese eine gespeicherte Suche).
|
||||||
|
func (s *Store) Delete(ctx context.Context, tenantSlug, userID string, id int64) error {
|
||||||
|
tag, err := s.pool.Exec(ctx, `
|
||||||
|
DELETE FROM mail_saved_searches WHERE tenant_slug = $1 AND user_id = $2 AND id = $3
|
||||||
|
`, tenantSlug, userID, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("savedsearch: löschen: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type rowScanner interface {
|
||||||
|
Scan(dest ...any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanSavedSearch(row rowScanner) (SavedSearch, error) {
|
||||||
|
var item SavedSearch
|
||||||
|
var filtersJSON []byte
|
||||||
|
if err := row.Scan(&item.ID, &item.TenantSlug, &item.UserID, &item.Name, &item.QueryText, &filtersJSON); err != nil {
|
||||||
|
return SavedSearch{}, fmt.Errorf("savedsearch: zeile lesen: %w", err)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(filtersJSON, &item.Filters); err != nil {
|
||||||
|
return SavedSearch{}, fmt.Errorf("savedsearch: filter parsen: %w", err)
|
||||||
|
}
|
||||||
|
return item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute führt eine gespeicherte Suche LIVE gegen client aus
|
||||||
|
// (Akzeptanzkriterium 2: aktuelle Treffer, kein eingefrorener Snapshot —
|
||||||
|
// Execute speichert selbst nichts zwischen, jeder Aufruf fragt Manticore
|
||||||
|
// neu ab). Hinterlegte Filter werden über search.Client.SearchWithFilters
|
||||||
|
// tatsächlich auf die Treffermenge angewandt (UND-Verknüpfung), nicht nur
|
||||||
|
// auf ihr Vorhandensein geprüft.
|
||||||
|
func Execute(ctx context.Context, client *search.Client, saved SavedSearch) ([]search.Result, error) {
|
||||||
|
return client.SearchWithFilters(ctx, saved.TenantSlug, saved.QueryText, saved.Filters)
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,12 +79,11 @@ func isFacetField(field string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
// buildFilteredMust baut die gemeinsame bool.must-Liste für Facets und
|
||||||
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
// SearchWithFilters: Tenant-Filter zwingend, optionaler Suchtext, dann je
|
||||||
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
// Filter eine zusätzliche equals-Klausel (UND-Verknüpfung) — einzige
|
||||||
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
// Stelle, an der Filter-Feldnamen gegen FacetFields geprüft werden.
|
||||||
// wie der Tenant-Filter).
|
func buildFilteredMust(tenantSlug, queryText string, filters []FacetFilter) ([]map[string]any, error) {
|
||||||
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
|
||||||
must := []map[string]any{
|
must := []map[string]any{
|
||||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||||
}
|
}
|
||||||
@@ -93,10 +92,71 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte
|
|||||||
}
|
}
|
||||||
for _, f := range filters {
|
for _, f := range filters {
|
||||||
if !isFacetField(f.Field) {
|
if !isFacetField(f.Field) {
|
||||||
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
return nil, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
||||||
}
|
}
|
||||||
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
||||||
}
|
}
|
||||||
|
return must, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchWithFilters ist Search, zusätzlich beschränkt auf Dokumente, die
|
||||||
|
// ALLE angegebenen Filter erfüllen (UND-Verknüpfung, dieselbe Semantik wie
|
||||||
|
// Facets) — Grundlage für SRC-08s Wiederausführung gespeicherter Suchen
|
||||||
|
// mit Filtern.
|
||||||
|
func (c *Client) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) ([]Result, error) {
|
||||||
|
if len(filters) == 0 {
|
||||||
|
return c.Search(ctx, tenantSlug, queryText)
|
||||||
|
}
|
||||||
|
must, err := buildFilteredMust(tenantSlug, queryText, filters)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]any{
|
||||||
|
"index": IndexName,
|
||||||
|
"query": map[string]any{"bool": map[string]any{"must": must}},
|
||||||
|
"options": map[string]any{
|
||||||
|
"field_weights": fieldWeights,
|
||||||
|
},
|
||||||
|
"limit": searchResultLimit,
|
||||||
|
}
|
||||||
|
body, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("search: gefilterte suchanfrage serialisieren: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := c.doSearchWithSwapRetry(ctx, body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed searchResponse
|
||||||
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("search: antwort parsen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]Result, 0, len(parsed.Hits.Hits))
|
||||||
|
for _, hit := range parsed.Hits.Hits {
|
||||||
|
results = append(results, Result{
|
||||||
|
MessageID: hit.Source.MessageID,
|
||||||
|
Subject: hit.Source.Subject,
|
||||||
|
Score: hit.Score,
|
||||||
|
SentAtUnixEpoch: hit.Source.SentAtUnixEpoch,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
||||||
|
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
||||||
|
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
||||||
|
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
||||||
|
// wie der Tenant-Filter).
|
||||||
|
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
||||||
|
must, err := buildFilteredMust(tenantSlug, queryText, filters)
|
||||||
|
if err != nil {
|
||||||
|
return FacetResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
aggs := map[string]any{}
|
aggs := map[string]any{}
|
||||||
for _, field := range FacetFields {
|
for _, field := range FacetFields {
|
||||||
|
|||||||
Reference in New Issue
Block a user