194 lines
6.4 KiB
Go
194 lines
6.4 KiB
Go
package opensearch
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"strings"
|
||
|
|
"sync"
|
||
|
|
"testing"
|
||
|
|
)
|
||
|
|
|
||
|
|
// TestTransformSourceID covers the generated-question / regular / fallback
|
||
|
|
// branches that CopyIndices uses to remap source_id.
|
||
|
|
func TestTransformSourceID(t *testing.T) {
|
||
|
|
t.Run("regular chunk uses target chunk id", func(t *testing.T) {
|
||
|
|
if got := transformSourceID("chunk1", "chunk1", "tgt1"); got != "tgt1" {
|
||
|
|
t.Errorf("want tgt1, got %s", got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
t.Run("generated question preserves question id", func(t *testing.T) {
|
||
|
|
if got := transformSourceID("chunk1-q7", "chunk1", "tgt1"); got != "tgt1-q7" {
|
||
|
|
t.Errorf("want tgt1-q7, got %s", got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
t.Run("unrelated source id gets fresh uuid", func(t *testing.T) {
|
||
|
|
got := transformSourceID("totally-different", "chunk1", "tgt1")
|
||
|
|
if got == "totally-different" || got == "tgt1" || len(got) != 36 {
|
||
|
|
t.Errorf("want fresh uuid, got %q", got)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestCopyIndices_EmptyMapping_NoOp verifies an empty chunk map short-circuits
|
||
|
|
// before any HTTP call.
|
||
|
|
func TestCopyIndices_EmptyMapping_NoOp(t *testing.T) {
|
||
|
|
repo, ts := newTestRepo(t, func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
t.Errorf("unexpected HTTP call: %s %s", r.Method, r.URL.Path)
|
||
|
|
})
|
||
|
|
defer ts.Close()
|
||
|
|
err := repo.CopyIndices(context.Background(), "kbSrc", map[string]string{}, map[string]string{}, "kbDst", 768, "manual")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("want nil, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestCopyIndices_ScanThenBatchSave verifies the search→BatchSave path:
|
||
|
|
// remaps IDs, keys the embedding by the *target source id* (OpenSearch
|
||
|
|
// BatchSave's lookup key), and emits one reindex audit event.
|
||
|
|
func TestCopyIndices_ScanThenBatchSave(t *testing.T) {
|
||
|
|
var (
|
||
|
|
mu sync.Mutex
|
||
|
|
bulkBody string
|
||
|
|
searchCnt int
|
||
|
|
)
|
||
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
switch {
|
||
|
|
case r.Method == http.MethodHead:
|
||
|
|
w.WriteHeader(http.StatusOK) // alias exists → ensureReady no-op
|
||
|
|
case strings.Contains(r.URL.Path, "_search"):
|
||
|
|
mu.Lock()
|
||
|
|
searchCnt++
|
||
|
|
first := searchCnt == 1
|
||
|
|
mu.Unlock()
|
||
|
|
if first {
|
||
|
|
_, _ = w.Write([]byte(`{"hits":{"hits":[
|
||
|
|
{"_source":{"content":"c","source_id":"srcChunk","source_type":1,"chunk_id":"srcChunk","knowledge_id":"srcKnow","knowledge_base_id":"kbSrc","tag_id":"t","is_enabled":true,"is_recommended":false,"embedding":[0.1,0.2,0.3]}}
|
||
|
|
]}}`))
|
||
|
|
} else {
|
||
|
|
_, _ = w.Write([]byte(`{"hits":{"hits":[]}}`))
|
||
|
|
}
|
||
|
|
case strings.HasSuffix(r.URL.Path, "/_bulk"):
|
||
|
|
b, _ := io.ReadAll(r.Body)
|
||
|
|
mu.Lock()
|
||
|
|
bulkBody = string(b)
|
||
|
|
mu.Unlock()
|
||
|
|
_, _ = w.Write([]byte(`{"errors":false,"items":[]}`))
|
||
|
|
default:
|
||
|
|
_, _ = w.Write([]byte(`{}`))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
repo, ts := newTestRepo(t, handler)
|
||
|
|
defer ts.Close()
|
||
|
|
spy := &spySink{}
|
||
|
|
repo.sink = spy
|
||
|
|
|
||
|
|
err := repo.CopyIndices(context.Background(), "kbSrc",
|
||
|
|
map[string]string{"srcKnow": "tgtKnow"}, // knowledge_id remap (sourceToTargetKBIDMap is keyed by knowledge_id, mirroring ES)
|
||
|
|
map[string]string{"srcChunk": "tgtChunk"},
|
||
|
|
"kbDst", 768, "manual")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("CopyIndices: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
defer mu.Unlock()
|
||
|
|
if bulkBody == "" {
|
||
|
|
t.Fatal("no bulk request captured")
|
||
|
|
}
|
||
|
|
// Target IDs present, source IDs gone from the written doc.
|
||
|
|
for _, want := range []string{`"chunk_id":"tgtChunk"`, `"knowledge_id":"tgtKnow"`, `"knowledge_base_id":"kbDst"`, `"source_id":"tgtChunk"`} {
|
||
|
|
if !strings.Contains(bulkBody, want) {
|
||
|
|
t.Errorf("bulk body missing %q\n%s", want, bulkBody)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if strings.Contains(bulkBody, `"knowledge_base_id":"kbSrc"`) {
|
||
|
|
t.Errorf("bulk body leaked source KB id\n%s", bulkBody)
|
||
|
|
}
|
||
|
|
// Embedding written (keyed by target source id internally).
|
||
|
|
if !strings.Contains(bulkBody, "0.1") {
|
||
|
|
t.Errorf("embedding not written\n%s", bulkBody)
|
||
|
|
}
|
||
|
|
if len(spy.reindex) != 1 || spy.reindex[0].docs != 1 {
|
||
|
|
t.Errorf("want 1 reindex event with docs=1, got %+v", spy.reindex)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestBatchUpdateChunkEnabledStatus_GroupedUpdateByQuery verifies the status
|
||
|
|
// map is grouped by value into one _update_by_query per distinct value, each
|
||
|
|
// passing chunk ids via terms + the new value via bound script params.
|
||
|
|
func TestBatchUpdateChunkEnabledStatus_GroupedUpdateByQuery(t *testing.T) {
|
||
|
|
var (
|
||
|
|
mu sync.Mutex
|
||
|
|
bodies []string
|
||
|
|
)
|
||
|
|
handler := func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if strings.Contains(r.URL.Path, "_update_by_query") {
|
||
|
|
b, _ := io.ReadAll(r.Body)
|
||
|
|
mu.Lock()
|
||
|
|
bodies = append(bodies, string(b))
|
||
|
|
mu.Unlock()
|
||
|
|
_, _ = w.Write([]byte(`{"updated":1,"version_conflicts":0,"failures":[]}`))
|
||
|
|
return
|
||
|
|
}
|
||
|
|
_, _ = w.Write([]byte(`{}`))
|
||
|
|
}
|
||
|
|
repo, ts := newTestRepo(t, handler)
|
||
|
|
defer ts.Close()
|
||
|
|
|
||
|
|
err := repo.BatchUpdateChunkEnabledStatus(context.Background(), map[string]bool{
|
||
|
|
"c1": true, "c2": false, "c3": true,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatalf("BatchUpdateChunkEnabledStatus: %v", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
mu.Lock()
|
||
|
|
defer mu.Unlock()
|
||
|
|
if len(bodies) != 2 {
|
||
|
|
t.Fatalf("want 2 grouped update_by_query calls (true/false), got %d: %v", len(bodies), bodies)
|
||
|
|
}
|
||
|
|
joined := strings.Join(bodies, "\n")
|
||
|
|
for _, want := range []string{"c1", "c2", "c3", "is_enabled", "params"} {
|
||
|
|
if !strings.Contains(joined, want) {
|
||
|
|
t.Errorf("update_by_query bodies missing %q\n%s", want, joined)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestBatchUpdateChunkEnabledStatus_Empty_NoOp(t *testing.T) {
|
||
|
|
repo, ts := newTestRepo(t, func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
t.Errorf("unexpected HTTP call: %s %s", r.Method, r.URL.Path)
|
||
|
|
})
|
||
|
|
defer ts.Close()
|
||
|
|
if err := repo.BatchUpdateChunkEnabledStatus(context.Background(), map[string]bool{}); err != nil {
|
||
|
|
t.Fatalf("want nil, got %v", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// TestInspectByQueryResponse covers the success path and the failure path,
|
||
|
|
// asserting cluster-side reason text is NOT surfaced in the returned error.
|
||
|
|
func TestInspectByQueryResponse(t *testing.T) {
|
||
|
|
t.Run("clean response", func(t *testing.T) {
|
||
|
|
body := strings.NewReader(`{"updated":5,"version_conflicts":0,"failures":[]}`)
|
||
|
|
if err := inspectByQueryResponse(body); err != nil {
|
||
|
|
t.Fatalf("want nil, got %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
t.Run("failures do not leak reason", func(t *testing.T) {
|
||
|
|
body := strings.NewReader(`{"updated":1,"version_conflicts":0,"failures":[
|
||
|
|
{"id":"c9","cause":{"type":"version_conflict_engine_exception","reason":"SECRET document body leaked here"}}
|
||
|
|
]}`)
|
||
|
|
err := inspectByQueryResponse(body)
|
||
|
|
if err == nil {
|
||
|
|
t.Fatal("want error for failures, got nil")
|
||
|
|
}
|
||
|
|
if strings.Contains(err.Error(), "SECRET") {
|
||
|
|
t.Errorf("error leaked cluster reason: %v", err)
|
||
|
|
}
|
||
|
|
if !strings.Contains(err.Error(), "version_conflict_engine_exception") {
|
||
|
|
t.Errorf("error should surface bounded type: %v", err)
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|