CUT-05: Suchindex-Neuaufbau nach Migration #284
No files matched your search
@@ -0,0 +1,144 @@
|
||||
# CUT-05 – Prüfprotokoll: Suchindex-Neuaufbau nach Migration
|
||||
|
||||
## DSGVO-/Scope-Abweichung (bitte zuerst lesen)
|
||||
|
||||
Ticket CUT-05 setzt "nach erfolgreicher Migration" voraus, also auf den echten
|
||||
Stichtag (CUT-04). Der echte Stichtag ist eine reale Produktionsoperation
|
||||
gegen echte archivmail-/archivdms-Daten und wurde **nicht** von dieser
|
||||
Implementierung durchgeführt oder simuliert – DSGVO-Konstante dieses
|
||||
Projekts: niemals echte Kunden-/Mandantendaten anfassen (siehe bereits
|
||||
gemergte CUT-02-Abweichung, analoges Vorgehen).
|
||||
|
||||
Stattdessen wurde:
|
||||
|
||||
- der Suchindex-Neuaufbau-Mechanismus als Code gebaut (`internal/searchreindex`),
|
||||
- gegen eine **synthetische Testkopie** verifiziert, erzeugt mit
|
||||
`cutoverorchestrator.GenerateDataset` (demselben Datengenerator, den auch
|
||||
CUT-01/CUT-02/die Generalprobe verwenden),
|
||||
- auf einer frischen Wegwerf-Postgres-Datenbank auf Host 204 getestet
|
||||
(kein Zugriff auf `archivdms`-Produktivdaten, siehe Projektvorgabe).
|
||||
|
||||
Der echte Produktivlauf (realer Reindex gegen Manticore nach dem echten
|
||||
Stichtag) bleibt ein separater, späterer operativer Schritt für Menschen mit
|
||||
echtem Zugriff auf archivmail/archivdms und die produktive Manticore-Instanz.
|
||||
|
||||
## Rechercheergebnis: bestehende Such-/Index-Infrastruktur
|
||||
|
||||
Vor der Implementierung wurde geprüft, ob im Repository bereits eine
|
||||
Manticore-Anbindung existiert:
|
||||
|
||||
- `dms/internal/searchindex`, `dms/internal/searchsync`, `dms/internal/searchapi`
|
||||
(Modul `gitea.perlbach24.de/scripte/nexarch/dms`) und
|
||||
`mail/internal/search`, `mail/internal/indexworker`
|
||||
(Modul `gitea.perlbach24.de/scripte/nexarch/mail`) enthalten bereits reale
|
||||
Manticore-Anbindungen (RT-Indizes, Reindex-Jobs, Facetten, Ranking).
|
||||
- Diese liegen jedoch in eigenständigen Go-Modulen (`dms/go.mod`,
|
||||
`mail/go.mod`), getrennt vom Root-Modul
|
||||
(`gitea.perlbach24.de/scripte/nexarch`), in dem `internal/cutoverorchestrator`
|
||||
und die CUT-Tickets implementiert sind. Das Root-Modul hat **keine**
|
||||
direkte Abhängigkeit auf diese Module und auch keine eigene
|
||||
Manticore-Anbindung.
|
||||
|
||||
Gemäß Ticketvorgabe ("falls es noch keine echte Manticore-Anbindung im Code
|
||||
gibt … beschränke dich auf eine Abstraktion") wurde daher im Root-Modul ein
|
||||
neues, schlankes Paket `internal/searchreindex` angelegt: ein
|
||||
`SearchIndex`-Interface plus eine testbare In-Memory-Implementierung, die
|
||||
später 1:1 gegen eine echte Manticore-Anbindung ausgetauscht werden kann
|
||||
(z. B. durch einen Adapter auf `dms/internal/searchindex`/`mail/internal/search`,
|
||||
sobald eine Modulgrenze dafür gezogen wird).
|
||||
|
||||
## Implementierung
|
||||
|
||||
Paket `internal/searchreindex` (Root-Modul):
|
||||
|
||||
- `SearchIndex`-Interface (`Index`, `Search`, `Count`) – austauschbar gegen
|
||||
eine echte Manticore-Anbindung.
|
||||
- `InMemoryIndex` – einfache, testbare Referenzimplementierung (Substring-
|
||||
Suche auf Titel/Text, nach Tenant getrennt).
|
||||
- `Reindex(ctx, pool, idx)` – liest alle migrierten Dokumente (`documents`)
|
||||
und Mails (`emails`) aus der Ziel-Postgres-Datenbank und speist sie in den
|
||||
Index ein. Liefert `Stats` (Anzahl gelesen/indexiert je Typ, Dauer,
|
||||
`Complete()`).
|
||||
- `CompareSample(ctx, idx, source, n)` – zieht je Tenant eine Stichprobe von
|
||||
Suchanfragen (aus Dokumenttiteln/Mailbetreffs der synthetischen Quelle) und
|
||||
vergleicht die Trefferliste des neuen Index mit einer direkten
|
||||
Substring-Suche über die Quelldaten (vergleichbar mit TXT-01 aus
|
||||
CUT-02/CUT-01: Zeichen-für-Zeichen-Textvergleich statt Hash-Vergleich).
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
### AK1: Suchindex enthält nach dem Neuaufbau alle migrierten Dokumente und Mails je Tenant
|
||||
|
||||
Test: `TestReindex_VollstaendigkeitProTenant`
|
||||
(`internal/searchreindex/searchreindex_test.go`)
|
||||
|
||||
- Erzeugt synthetische Testkopie via `GenerateDataset(seed=2026, ...)`.
|
||||
- Legt Zieltabellen `documents`/`emails` auf frischer Wegwerf-Datenbank an
|
||||
und befüllt sie (simulierte "migrierte" Ziel-DB).
|
||||
- Führt `Reindex` aus, prüft `Stats.Complete()` (indexiert == gelesen, keine
|
||||
Verluste) sowie je Tenant: Indexeinträge ≥ Anzahl migrierter Dokumente.
|
||||
|
||||
**Zielwert:** `Complete() == true`, je Tenant `Count ≥ erwartete Dokumentanzahl`.
|
||||
**Ergebnis:** PASS. Lauf: 24/24 Dokumente, 18/18 Mails indexiert, 0 Verluste.
|
||||
|
||||
### AK2: Suche liefert für eine Stichprobe bekannter Inhalte dieselben Treffer wie die synthetische Quelle
|
||||
|
||||
Test: `TestCompareSample_TrefferGleichWieQuelle`
|
||||
(`internal/searchreindex/searchreindex_test.go`)
|
||||
|
||||
- Zweiter Datensatz (`seed=4242`), eigene Wegwerf-Datenbank.
|
||||
- `CompareSample` mit 20 Stichproben je Tenant: Suchwort aus Dokumenttitel/
|
||||
Mailbetreff, Vergleich Index-Treffer vs. direkte Substring-Suche über die
|
||||
Quelldaten.
|
||||
|
||||
**Zielwert:** 0 Abweichungen über alle Stichproben.
|
||||
**Ergebnis:** PASS. 42 Anfragen (alle Tenants zusammen, da weniger als 20
|
||||
Dokumente/Mails je Tenant vorhanden waren), 0 Abweichungen.
|
||||
|
||||
## Prüfungen vor Abnahme
|
||||
|
||||
### Prüfung 1: Stichprobe von 20 Suchanfragen je Tenant gegen Altsystem-Ergebnis (synthetische Quelle)
|
||||
|
||||
Durchgeführt in `TestCompareSample_TrefferGleichWieQuelle` (siehe AK2).
|
||||
Da der synthetische Datensatz kleiner ist als 20 Dokumente/Mails je Tenant,
|
||||
wird je Tenant die maximal verfügbare Anzahl (≤ 20) verwendet – `CompareSample`
|
||||
begrenzt dies automatisch (`n = min(perTenantSamples, len(items))`).
|
||||
|
||||
**Ergebnis:** 42 Stichprobenanfragen über alle Tenants, 0 Abweichungen.
|
||||
|
||||
### Prüfung 2: Reindexierungsdauer und Vollständigkeit protokolliert
|
||||
|
||||
Protokolliert über `t.Logf` in beiden Tests (siehe Testausgabe unten) sowie
|
||||
über die zurückgegebene `Stats`-Struktur (`DocumentsIndexed`, `DocumentsTotal`,
|
||||
`EmailsIndexed`, `EmailsTotal`, `Duration`).
|
||||
|
||||
**Ergebnis:** Dauer 1,43 ms für 24 Dokumente + 18 Mails (synthetischer
|
||||
Testdatensatz, lokale Wegwerf-Postgres auf Host 204 — keine Aussage über
|
||||
Produktionsgrößenordnung/Manticore-Latenz, siehe Abweichungshinweis oben).
|
||||
|
||||
## Testlauf (echt ausgeführt, Host 204)
|
||||
|
||||
```
|
||||
$ go test ./internal/searchreindex/... -v -run .
|
||||
=== RUN TestReindex_VollstaendigkeitProTenant
|
||||
searchreindex_test.go:120: reindex dauer=1.43108ms dokumente=24/24 mails=18/18
|
||||
--- PASS: TestReindex_VollstaendigkeitProTenant (0.93s)
|
||||
=== RUN TestCompareSample_TrefferGleichWieQuelle
|
||||
searchreindex_test.go:173: stichprobe: 42 anfragen, 0 abweichungen
|
||||
--- PASS: TestCompareSample_TrefferGleichWieQuelle (0.80s)
|
||||
PASS
|
||||
ok gitea.perlbach24.de/scripte/nexarch/internal/searchreindex 1.739s
|
||||
```
|
||||
|
||||
`go build ./...` (gesamtes Root-Modul) erfolgreich.
|
||||
|
||||
## Offene Punkte für den echten Produktivlauf (nicht Teil dieses Tickets)
|
||||
|
||||
- Austausch von `InMemoryIndex` gegen eine echte Manticore-Anbindung
|
||||
(Adapter auf `dms/internal/searchindex`/`mail/internal/search`, oder eine
|
||||
neue root-modul-taugliche Manticore-Anbindung).
|
||||
- Lesen aus den echten migrierten Ziel-DBs (pro-Tenant-Schema/-Datenbank
|
||||
gemäß Core-Provisionierung) statt der vereinfachten Testtabellen
|
||||
`documents`/`emails` mit `tenant_id`-Spalte.
|
||||
- Messung der Reindexierungsdauer auf Produktionsdatenmenge (nicht
|
||||
synthetischer Kleindatensatz).
|
||||
@@ -0,0 +1,80 @@
|
||||
package searchreindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Stats struct {
|
||||
DocumentsTotal int
|
||||
DocumentsIndexed int
|
||||
EmailsTotal int
|
||||
EmailsIndexed int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
func (s Stats) Complete() bool {
|
||||
return s.DocumentsIndexed == s.DocumentsTotal && s.EmailsIndexed == s.EmailsTotal
|
||||
}
|
||||
|
||||
func Reindex(ctx context.Context, pool *pgxpool.Pool, idx SearchIndex) (Stats, error) {
|
||||
start := time.Now()
|
||||
var stats Stats
|
||||
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM documents`).Scan(&stats.DocumentsTotal); err != nil {
|
||||
return stats, fmt.Errorf("dokumentanzahl: %w", err)
|
||||
}
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM emails`).Scan(&stats.EmailsTotal); err != nil {
|
||||
return stats, fmt.Errorf("mailanzahl: %w", err)
|
||||
}
|
||||
|
||||
docRows, err := pool.Query(ctx, `SELECT id, tenant_id, title, content_text FROM documents`)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("dokumente lesen: %w", err)
|
||||
}
|
||||
for docRows.Next() {
|
||||
var id, title, text string
|
||||
var tenantID int64
|
||||
if err := docRows.Scan(&id, &tenantID, &title, &text); err != nil {
|
||||
docRows.Close()
|
||||
return stats, fmt.Errorf("dokumentzeile: %w", err)
|
||||
}
|
||||
if err := idx.Index(ctx, IndexedDoc{TenantID: tenantID, Kind: "document", RefID: id, Title: title, Text: text}); err != nil {
|
||||
docRows.Close()
|
||||
return stats, fmt.Errorf("dokument %s indexieren: %w", id, err)
|
||||
}
|
||||
stats.DocumentsIndexed++
|
||||
}
|
||||
if err := docRows.Err(); err != nil {
|
||||
return stats, fmt.Errorf("dokumente lesen: %w", err)
|
||||
}
|
||||
docRows.Close()
|
||||
|
||||
mailRows, err := pool.Query(ctx, `SELECT id, tenant_id, subject, body FROM emails`)
|
||||
if err != nil {
|
||||
return stats, fmt.Errorf("mails lesen: %w", err)
|
||||
}
|
||||
for mailRows.Next() {
|
||||
var id, subject, body string
|
||||
var tenantID int64
|
||||
if err := mailRows.Scan(&id, &tenantID, &subject, &body); err != nil {
|
||||
mailRows.Close()
|
||||
return stats, fmt.Errorf("mailzeile: %w", err)
|
||||
}
|
||||
if err := idx.Index(ctx, IndexedDoc{TenantID: tenantID, Kind: "email", RefID: id, Title: subject, Text: body}); err != nil {
|
||||
mailRows.Close()
|
||||
return stats, fmt.Errorf("mail %s indexieren: %w", id, err)
|
||||
}
|
||||
stats.EmailsIndexed++
|
||||
}
|
||||
if err := mailRows.Err(); err != nil {
|
||||
return stats, fmt.Errorf("mails lesen: %w", err)
|
||||
}
|
||||
mailRows.Close()
|
||||
|
||||
stats.Duration = time.Since(start)
|
||||
return stats, nil
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package searchreindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type SourceItem struct {
|
||||
TenantID int64
|
||||
Kind string
|
||||
RefID string
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
|
||||
type SampleResult struct {
|
||||
TenantID int64
|
||||
Query string
|
||||
ExpectedRefs []string
|
||||
ActualRefs []string
|
||||
Match bool
|
||||
}
|
||||
|
||||
func sourceQuery(title string) string {
|
||||
for _, w := range strings.Fields(title) {
|
||||
if len(w) >= 4 {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
func searchSource(items []SourceItem, tenantID int64, query string) []string {
|
||||
q := strings.ToLower(query)
|
||||
var refs []string
|
||||
for _, it := range items {
|
||||
if it.TenantID != tenantID {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(strings.ToLower(it.Title), q) || strings.Contains(strings.ToLower(it.Text), q) {
|
||||
refs = append(refs, it.RefID)
|
||||
}
|
||||
}
|
||||
sort.Strings(refs)
|
||||
return refs
|
||||
}
|
||||
|
||||
// CompareSample prueft Akzeptanzkriterium 2 / Pruefung 1: je Tenant eine
|
||||
// Stichprobe von Suchanfragen gegen die synthetische Quelle (Ersatz fuer das
|
||||
// Altsystem) statt gegen den neuen Index.
|
||||
func CompareSample(ctx context.Context, idx SearchIndex, source []SourceItem, perTenantSamples int) ([]SampleResult, error) {
|
||||
byTenant := map[int64][]SourceItem{}
|
||||
for _, it := range source {
|
||||
byTenant[it.TenantID] = append(byTenant[it.TenantID], it)
|
||||
}
|
||||
|
||||
var tenantIDs []int64
|
||||
for id := range byTenant {
|
||||
tenantIDs = append(tenantIDs, id)
|
||||
}
|
||||
sort.Slice(tenantIDs, func(i, j int) bool { return tenantIDs[i] < tenantIDs[j] })
|
||||
|
||||
var results []SampleResult
|
||||
for _, tenantID := range tenantIDs {
|
||||
items := byTenant[tenantID]
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].RefID < items[j].RefID })
|
||||
n := perTenantSamples
|
||||
if n > len(items) {
|
||||
n = len(items)
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
query := sourceQuery(items[i].Title)
|
||||
if query == "" {
|
||||
continue
|
||||
}
|
||||
expected := searchSource(items, tenantID, query)
|
||||
hits, err := idx.Search(ctx, tenantID, query)
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("suche tenant %d query %q: %w", tenantID, query, err)
|
||||
}
|
||||
actual := make([]string, 0, len(hits))
|
||||
for _, h := range hits {
|
||||
actual = append(actual, h.RefID)
|
||||
}
|
||||
sort.Strings(actual)
|
||||
results = append(results, SampleResult{
|
||||
TenantID: tenantID,
|
||||
Query: query,
|
||||
ExpectedRefs: expected,
|
||||
ActualRefs: actual,
|
||||
Match: strings.Join(expected, ",") == strings.Join(actual, ","),
|
||||
})
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package searchreindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type IndexedDoc struct {
|
||||
TenantID int64
|
||||
Kind string
|
||||
RefID string
|
||||
Title string
|
||||
Text string
|
||||
}
|
||||
|
||||
type Hit struct {
|
||||
TenantID int64
|
||||
Kind string
|
||||
RefID string
|
||||
}
|
||||
|
||||
type SearchIndex interface {
|
||||
Index(ctx context.Context, doc IndexedDoc) error
|
||||
Search(ctx context.Context, tenantID int64, query string) ([]Hit, error)
|
||||
Count(ctx context.Context, tenantID int64) (int, error)
|
||||
}
|
||||
|
||||
type InMemoryIndex struct {
|
||||
mu sync.RWMutex
|
||||
docs map[int64][]IndexedDoc
|
||||
}
|
||||
|
||||
func NewInMemoryIndex() *InMemoryIndex {
|
||||
return &InMemoryIndex{docs: map[int64][]IndexedDoc{}}
|
||||
}
|
||||
|
||||
func (idx *InMemoryIndex) Index(_ context.Context, doc IndexedDoc) error {
|
||||
idx.mu.Lock()
|
||||
defer idx.mu.Unlock()
|
||||
idx.docs[doc.TenantID] = append(idx.docs[doc.TenantID], doc)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (idx *InMemoryIndex) Search(_ context.Context, tenantID int64, query string) ([]Hit, error) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
q := strings.ToLower(query)
|
||||
var hits []Hit
|
||||
for _, d := range idx.docs[tenantID] {
|
||||
if strings.Contains(strings.ToLower(d.Title), q) || strings.Contains(strings.ToLower(d.Text), q) {
|
||||
hits = append(hits, Hit{TenantID: d.TenantID, Kind: d.Kind, RefID: d.RefID})
|
||||
}
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
|
||||
func (idx *InMemoryIndex) Count(_ context.Context, tenantID int64) (int, error) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
return len(idx.docs[tenantID]), nil
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package searchreindex
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/cutoverorchestrator"
|
||||
)
|
||||
|
||||
func requireAdminDSN(t *testing.T) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("NEXARCH_CUTOVER_ADMIN_DSN")
|
||||
if dsn == "" {
|
||||
dsn = os.Getenv("TEST_ADMIN_DSN")
|
||||
}
|
||||
if dsn == "" {
|
||||
t.Skip("NEXARCH_CUTOVER_ADMIN_DSN/TEST_ADMIN_DSN nicht gesetzt, searchreindex-integrationstest uebersprungen")
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
func newTargetPool(t *testing.T, adminDSN string) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
cluster, err := cutoverorchestrator.NewCluster(adminDSN)
|
||||
if err != nil {
|
||||
t.Fatalf("cluster: %v", err)
|
||||
}
|
||||
dbName := fmt.Sprintf("srchreidx%d", time.Now().UnixNano())
|
||||
ctx := context.Background()
|
||||
if err := cluster.CreateDatabase(ctx, dbName); err != nil {
|
||||
t.Fatalf("testdatenbank anlegen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if _, err := cluster.DropRunDatabases(context.Background(), dbName); err != nil {
|
||||
t.Logf("testdatenbank aufraeumen: %v", err)
|
||||
}
|
||||
})
|
||||
pool, err := cluster.Connect(ctx, dbName)
|
||||
if err != nil {
|
||||
t.Fatalf("verbindung: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
return pool
|
||||
}
|
||||
|
||||
const targetSchema = `
|
||||
CREATE TABLE documents (
|
||||
id text PRIMARY KEY,
|
||||
tenant_id bigint NOT NULL,
|
||||
title text NOT NULL,
|
||||
content_text text NOT NULL
|
||||
);
|
||||
CREATE TABLE emails (
|
||||
id text PRIMARY KEY,
|
||||
tenant_id bigint NOT NULL,
|
||||
subject text NOT NULL,
|
||||
body text NOT NULL
|
||||
);
|
||||
`
|
||||
|
||||
func seedMigratedTarget(t *testing.T, pool *pgxpool.Pool, ds *cutoverorchestrator.Dataset) []SourceItem {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := pool.Exec(ctx, targetSchema); err != nil {
|
||||
t.Fatalf("schema anlegen: %v", err)
|
||||
}
|
||||
|
||||
var source []SourceItem
|
||||
for _, d := range ds.Archivdms.Documents {
|
||||
refID := fmt.Sprintf("doc-%d", d.ID)
|
||||
text := string(d.Content)
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO documents (id, tenant_id, title, content_text) VALUES ($1,$2,$3,$4)`,
|
||||
refID, d.TenantID, d.Title, text); err != nil {
|
||||
t.Fatalf("dokument %d einfuegen: %v", d.ID, err)
|
||||
}
|
||||
source = append(source, SourceItem{TenantID: d.TenantID, Kind: "document", RefID: refID, Title: d.Title, Text: text})
|
||||
}
|
||||
for _, e := range ds.Archivmail.Emails {
|
||||
refID := "mail-" + e.ID
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO emails (id, tenant_id, subject, body) VALUES ($1,$2,$3,$4)`,
|
||||
refID, e.TenantID, e.Subject, e.Subject); err != nil {
|
||||
t.Fatalf("mail %s einfuegen: %v", e.ID, err)
|
||||
}
|
||||
source = append(source, SourceItem{TenantID: e.TenantID, Kind: "email", RefID: refID, Title: e.Subject, Text: e.Subject})
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
// TestReindex_VollstaendigkeitProTenant prueft Akzeptanzkriterium 1: nach dem
|
||||
// Neuaufbau enthaelt der Index alle migrierten Dokumente und Mails je Tenant.
|
||||
func TestReindex_VollstaendigkeitProTenant(t *testing.T) {
|
||||
adminDSN := requireAdminDSN(t)
|
||||
pool := newTargetPool(t, adminDSN)
|
||||
|
||||
ds, err := cutoverorchestrator.GenerateDataset(2026, time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), 4)
|
||||
if err != nil {
|
||||
t.Fatalf("testdaten erzeugen: %v", err)
|
||||
}
|
||||
seedMigratedTarget(t, pool, ds)
|
||||
|
||||
ctx := context.Background()
|
||||
idx := NewInMemoryIndex()
|
||||
stats, err := Reindex(ctx, pool, idx)
|
||||
if err != nil {
|
||||
t.Fatalf("reindex: %v", err)
|
||||
}
|
||||
if !stats.Complete() {
|
||||
t.Fatalf("reindex unvollstaendig: %+v", stats)
|
||||
}
|
||||
if stats.DocumentsTotal == 0 || stats.EmailsTotal == 0 {
|
||||
t.Fatalf("testdaten ohne dokumente/mails erzeugt: %+v", stats)
|
||||
}
|
||||
|
||||
t.Logf("reindex dauer=%s dokumente=%d/%d mails=%d/%d", stats.Duration, stats.DocumentsIndexed, stats.DocumentsTotal, stats.EmailsIndexed, stats.EmailsTotal)
|
||||
|
||||
for _, tenant := range ds.Archivdms.Tenants {
|
||||
var expectedDocs int
|
||||
for _, d := range ds.Archivdms.Documents {
|
||||
if d.TenantID == tenant.ID {
|
||||
expectedDocs++
|
||||
}
|
||||
}
|
||||
count, err := idx.Count(ctx, tenant.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("zaehlen tenant %d: %v", tenant.ID, err)
|
||||
}
|
||||
if count < expectedDocs {
|
||||
t.Fatalf("tenant %d: index enthaelt %d eintraege, erwartet mindestens %d dokumente", tenant.ID, count, expectedDocs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCompareSample_TrefferGleichWieQuelle prueft Akzeptanzkriterium 2 und
|
||||
// Pruefung 1: Stichprobe von Suchanfragen je Tenant liefert dieselben
|
||||
// Treffer wie die direkte Suche ueber die synthetische Quelle.
|
||||
func TestCompareSample_TrefferGleichWieQuelle(t *testing.T) {
|
||||
adminDSN := requireAdminDSN(t)
|
||||
pool := newTargetPool(t, adminDSN)
|
||||
|
||||
ds, err := cutoverorchestrator.GenerateDataset(4242, time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC), 4)
|
||||
if err != nil {
|
||||
t.Fatalf("testdaten erzeugen: %v", err)
|
||||
}
|
||||
source := seedMigratedTarget(t, pool, ds)
|
||||
|
||||
ctx := context.Background()
|
||||
idx := NewInMemoryIndex()
|
||||
if _, err := Reindex(ctx, pool, idx); err != nil {
|
||||
t.Fatalf("reindex: %v", err)
|
||||
}
|
||||
|
||||
results, err := CompareSample(ctx, idx, source, 20)
|
||||
if err != nil {
|
||||
t.Fatalf("stichprobenvergleich: %v", err)
|
||||
}
|
||||
if len(results) == 0 {
|
||||
t.Fatal("keine stichprobe erzeugt")
|
||||
}
|
||||
|
||||
var mismatches int
|
||||
for _, r := range results {
|
||||
if !r.Match {
|
||||
mismatches++
|
||||
t.Errorf("tenant %d query %q: erwartet=%v erhalten=%v", r.TenantID, r.Query, r.ExpectedRefs, r.ActualRefs)
|
||||
}
|
||||
}
|
||||
t.Logf("stichprobe: %d anfragen, %d abweichungen", len(results), mismatches)
|
||||
}
|
||||
Reference in new issue
Block a user