Files
MyGoNavi/internal/db/elasticsearch_impl_test.go
Syngnat 864ad8a371 ♻️ refactor(elasticsearch): 改用轻量 REST 客户端
- 使用标准库 HTTP 客户端实现 ES ping、索引、mapping 和查询请求

- 保留代理、TLS、超时和 BasicAuth 配置能力

- 移除 go-elasticsearch SDK 及间接依赖,降低 dev 构建下载风险

- 更新 Elasticsearch 后端单测适配 REST 客户端
2026-06-02 15:30:32 +08:00

1107 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build gonavi_full_drivers || gonavi_elasticsearch_driver
package db
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
// ---- 测试辅助函数 ----
// newMockESServer 创建模拟 Elasticsearch REST API 的 HTTP 测试服务器。
// 自动为所有响应添加 go-elasticsearch v8 客户端要求的 X-Elastic-Product 头。
func newMockESServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Elastic-Product", "Elasticsearch")
handler(w, r)
}))
t.Cleanup(server.Close)
return server
}
// newTestESClient 创建连接到测试服务器的 ES REST 客户端。
func newTestESClient(t *testing.T, serverURL string) *esRESTClient {
t.Helper()
client, err := newESRESTClient(esHTTPClientConfig{BaseURL: serverURL})
if err != nil {
t.Fatalf("创建测试 ES 客户端失败: %v", err)
}
return client
}
// newTestESDB 创建连接到测试服务器的 ElasticsearchDB 实例。
func newTestESDB(t *testing.T, serverURL, defaultIndex string) *ElasticsearchDB {
t.Helper()
return &ElasticsearchDB{
client: newTestESClient(t, serverURL),
database: defaultIndex,
}
}
// buildMockESMappingResponse 构造模拟的 mapping 响应 JSON。
func buildMockESMappingResponse(indexName string, fields map[string]string) map[string]interface{} {
properties := make(map[string]interface{})
for name, fieldType := range fields {
properties[name] = map[string]interface{}{"type": fieldType}
}
return map[string]interface{}{
indexName: map[string]interface{}{
"mappings": map[string]interface{}{
"properties": properties,
},
},
}
}
// writeJSON 将数据以 JSON 格式写入 HTTP 响应。
func writeJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(data)
}
// ---- 核心功能测试 ----
// TestElasticsearchPing 测试 Ping 成功和失败路径。
func TestElasticsearchPing(t *testing.T) {
t.Run("ping 成功", func(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
// Ping 使用 HEAD / 方法
if r.Method == http.MethodHead && r.URL.Path == "/" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "")
if err := db.Ping(); err != nil {
t.Fatalf("Ping 应成功,但返回错误:%v", err)
}
})
t.Run("ping 服务端返回错误", func(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
})
db := newTestESDB(t, server.URL, "")
err := db.Ping()
if err == nil {
t.Fatal("Ping 服务端 500 时应返回错误")
}
if !strings.Contains(err.Error(), "500") {
t.Fatalf("错误信息应包含状态码,实际:%v", err)
}
})
}
// TestElasticsearchGetDatabases 测试获取索引列表,验证隐藏索引过滤。
func TestElasticsearchGetDatabases(t *testing.T) {
t.Run("正常获取并过滤隐藏索引", func(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/_cat/indices") && r.Method == http.MethodGet {
writeJSON(w, []map[string]string{
{"index": "logs-2024"},
{"index": "users"},
{"index": ".security"},
{"index": ".kibana_1"},
{"index": "products"},
})
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "")
databases, err := db.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases 失败:%v", err)
}
slices.Sort(databases)
expected := []string{"logs-2024", "products", "users"}
if len(databases) != len(expected) {
t.Fatalf("期望 %d 个索引,实际 %d%v", len(expected), len(databases), databases)
}
for i, name := range expected {
if databases[i] != name {
t.Fatalf("索引 [%d] 期望 %q实际 %q", i, name, databases[i])
}
}
})
t.Run("连接未打开时返回错误", func(t *testing.T) {
db := &ElasticsearchDB{}
_, err := db.GetDatabases()
if err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("期望 '连接未打开' 错误,实际:%v", err)
}
})
}
// TestElasticsearchGetTables 测试 GetTables 返回索引名。
func TestElasticsearchGetTables(t *testing.T) {
t.Run("指定索引名", func(t *testing.T) {
db := &ElasticsearchDB{database: "default-index"}
tables, err := db.GetTables("my-index")
if err != nil {
t.Fatalf("GetTables 失败:%v", err)
}
if len(tables) != 1 || tables[0] != "my-index" {
t.Fatalf("期望 [my-index],实际:%v", tables)
}
})
t.Run("回退到默认索引", func(t *testing.T) {
db := &ElasticsearchDB{database: "default-index"}
tables, err := db.GetTables("")
if err != nil {
t.Fatalf("GetTables 失败:%v", err)
}
if len(tables) != 1 || tables[0] != "default-index" {
t.Fatalf("期望 [default-index],实际:%v", tables)
}
})
t.Run("无索引名时报错", func(t *testing.T) {
db := &ElasticsearchDB{}
_, err := db.GetTables("")
if err == nil || !strings.Contains(err.Error(), "未指定索引名") {
t.Fatalf("期望 '未指定索引名' 错误,实际:%v", err)
}
})
}
// TestElasticsearchGetColumns 测试从 mapping 中提取字段定义。
func TestElasticsearchGetColumns(t *testing.T) {
t.Run("正常提取字段", func(t *testing.T) {
fields := map[string]string{
"title": "text",
"status": "keyword",
"price": "float",
"quantity": "integer",
}
mapping := buildMockESMappingResponse("test-index", fields)
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/_mapping") && r.Method == http.MethodGet {
writeJSON(w, mapping)
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "test-index")
columns, err := db.GetColumns("test-index", "")
if err != nil {
t.Fatalf("GetColumns 失败:%v", err)
}
if len(columns) != 4 {
t.Fatalf("期望 4 个字段,实际 %d", len(columns))
}
// 验证字段类型映射
typeMap := make(map[string]string)
for _, col := range columns {
typeMap[col.Name] = col.Type
}
for name, expectedType := range fields {
if typeMap[name] != expectedType {
t.Fatalf("字段 %q 类型期望 %q实际 %q", name, expectedType, typeMap[name])
}
}
// 验证所有字段标记为可空
for _, col := range columns {
if col.Nullable != "YES" {
t.Fatalf("字段 %q Nullable 期望 YES实际 %q", col.Name, col.Nullable)
}
}
})
t.Run("服务端返回错误", func(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":"index_not_found"}`))
})
db := newTestESDB(t, server.URL, "test-index")
_, err := db.GetColumns("test-index", "")
if err == nil {
t.Fatal("GetColumns 服务端 404 时应返回错误")
}
})
t.Run("连接未打开时返回错误", func(t *testing.T) {
db := &ElasticsearchDB{}
_, err := db.GetColumns("test-index", "")
if err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("期望 '连接未打开' 错误,实际:%v", err)
}
})
}
// TestElasticsearchGetAllColumns 测试获取全部字段。
func TestElasticsearchGetAllColumns(t *testing.T) {
fields := map[string]string{
"name": "text",
"email": "keyword",
}
mapping := buildMockESMappingResponse("users", fields)
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "/_mapping") {
writeJSON(w, mapping)
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "users")
columns, err := db.GetAllColumns("")
if err != nil {
t.Fatalf("GetAllColumns 失败:%v", err)
}
if len(columns) != 2 {
t.Fatalf("期望 2 个字段,实际 %d", len(columns))
}
// 验证每个字段都带有表名标识
for _, col := range columns {
if col.TableName != "users" {
t.Fatalf("字段 %q 的 TableName 期望 users实际 %s", col.Name, col.TableName)
}
}
}
// TestElasticsearchQueryDSL 测试 JSON DSL 查询模式。
func TestElasticsearchQueryDSL(t *testing.T) {
t.Run("指定索引的 DSL 查询", func(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/_search") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"hits": {
"total": {"value": 1},
"hits": [
{"_index": "test-index", "_id": "1", "_source": {"title": "测试文档", "status": "active"}}
]
}
}`))
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "test-index")
dsl := `{"query":{"match_all":{}}}`
rows, columns, err := db.Query(dsl)
if err != nil {
t.Fatalf("DSL 查询失败:%v", err)
}
if len(rows) != 1 {
t.Fatalf("期望 1 条结果,实际 %d", len(rows))
}
// 验证包含 _index 和 _id 元数据列
colSet := make(map[string]bool)
for _, col := range columns {
colSet[col] = true
}
if !colSet["_index"] || !colSet["_id"] {
t.Fatalf("结果列应包含 _index 和 _id实际%v", columns)
}
// 验证数据内容
if rows[0]["title"] != "测试文档" {
t.Fatalf("期望 title=测试文档,实际:%v", rows[0]["title"])
}
if rows[0]["_index"] != "test-index" {
t.Fatalf("期望 _index=test-index实际%v", rows[0]["_index"])
}
})
t.Run("无默认索引时使用通配符", func(t *testing.T) {
var capturedPath string
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/_search") {
capturedPath = r.URL.Path
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"hits":{"total":{"value":0},"hits":[]}}`))
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "")
_, _, err := db.Query(`{"query":{"match_all":{}}}`)
if err != nil {
t.Fatalf("DSL 查询失败:%v", err)
}
if !strings.HasPrefix(capturedPath, "/*/_search") {
t.Fatalf("无默认索引时应使用 * 通配符查询,实际路径:%s", capturedPath)
}
})
t.Run("查询服务端返回错误", func(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"parsing_exception"}`))
})
db := newTestESDB(t, server.URL, "test-index")
_, _, err := db.Query(`{"query":{"invalid":{}}}`)
if err == nil {
t.Fatal("DSL 查询服务端错误时应返回错误")
}
})
}
// TestElasticsearchQueryString 测试 query_string 查询模式。
func TestElasticsearchQueryString(t *testing.T) {
t.Run("简单字符串查询", func(t *testing.T) {
var capturedBody string
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/_search") {
buf := make([]byte, r.ContentLength)
_, _ = r.Body.Read(buf)
capturedBody = string(buf)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"hits": {
"total": {"value": 2},
"hits": [
{"_index": "test", "_id": "1", "_source": {"title": "匹配结果1"}},
{"_index": "test", "_id": "2", "_source": {"title": "匹配结果2"}}
]
}
}`))
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "test")
rows, _, err := db.Query("hello world")
if err != nil {
t.Fatalf("query_string 查询失败:%v", err)
}
if len(rows) != 2 {
t.Fatalf("期望 2 条结果,实际 %d", len(rows))
}
// 验证请求体包含 query_string 包装
if !strings.Contains(capturedBody, "query_string") {
t.Fatalf("请求体应包含 query_string实际%s", capturedBody)
}
if !strings.Contains(capturedBody, "hello world") {
t.Fatalf("请求体应包含查询文本,实际:%s", capturedBody)
}
})
t.Run("查询语句为空时报错", func(t *testing.T) {
db := newTestESDB(t, "http://localhost:9200", "test")
_, _, err := db.Query(" ")
if err == nil || !strings.Contains(err.Error(), "查询语句不能为空") {
t.Fatalf("期望 '查询语句不能为空' 错误,实际:%v", err)
}
})
t.Run("连接未打开时返回错误", func(t *testing.T) {
db := &ElasticsearchDB{}
_, _, err := db.Query("test")
if err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("期望 '连接未打开' 错误,实际:%v", err)
}
})
}
// TestElasticsearchExecNotSupported 测试 Exec 返回不支持错误。
func TestElasticsearchExecNotSupported(t *testing.T) {
db := &ElasticsearchDB{}
rowsAffected, err := db.Exec("DELETE FROM test")
if err == nil || !strings.Contains(err.Error(), "不支持执行非查询语句") {
t.Fatalf("期望 '不支持执行非查询语句' 错误,实际:%v", err)
}
if rowsAffected != 0 {
t.Fatalf("Exec 应返回 0 受影响行数,实际:%d", rowsAffected)
}
}
// TestElasticsearchGetForeignKeys 测试返回空外键列表。
func TestElasticsearchGetForeignKeys(t *testing.T) {
db := &ElasticsearchDB{}
fks, err := db.GetForeignKeys("test-index", "test-table")
if err != nil {
t.Fatalf("GetForeignKeys 不应返回错误:%v", err)
}
if len(fks) != 0 {
t.Fatalf("GetForeignKeys 应返回空列表,实际:%v", fks)
}
}
// TestElasticsearchGetTriggers 测试返回空触发器列表。
func TestElasticsearchGetTriggers(t *testing.T) {
db := &ElasticsearchDB{}
triggers, err := db.GetTriggers("test-index", "test-table")
if err != nil {
t.Fatalf("GetTriggers 不应返回错误:%v", err)
}
if len(triggers) != 0 {
t.Fatalf("GetTriggers 应返回空列表,实际:%v", triggers)
}
}
// TestElasticsearchConnectNilClient 测试未连接时各操作返回错误。
func TestElasticsearchConnectNilClient(t *testing.T) {
db := &ElasticsearchDB{}
// Ping
if err := db.Ping(); err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("Ping: 期望 '连接未打开' 错误,实际:%v", err)
}
// GetDatabases
if _, err := db.GetDatabases(); err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("GetDatabases: 期望 '连接未打开' 错误,实际:%v", err)
}
// GetColumns
if _, err := db.GetColumns("idx", ""); err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("GetColumns: 期望 '连接未打开' 错误,实际:%v", err)
}
// GetAllColumns
if _, err := db.GetAllColumns("idx"); err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("GetAllColumns: 期望 '连接未打开' 错误,实际:%v", err)
}
// GetIndexes
if _, err := db.GetIndexes("idx", "tbl"); err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("GetIndexes: 期望 '连接未打开' 错误,实际:%v", err)
}
// GetCreateStatement间接通过 esFetchIndexMapping
if _, err := db.GetCreateStatement("idx", "tbl"); err == nil || !strings.Contains(err.Error(), "连接未打开") {
t.Fatalf("GetCreateStatement: 期望 '连接未打开' 错误,实际:%v", err)
}
}
// TestElasticsearchGetCreateStatement 测试获取索引 settings + mappings。
func TestElasticsearchGetCreateStatement(t *testing.T) {
indexDef := map[string]interface{}{
"test-index": map[string]interface{}{
"settings": map[string]interface{}{
"index": map[string]interface{}{
"number_of_shards": "1",
"number_of_replicas": "0",
},
},
"mappings": map[string]interface{}{
"properties": map[string]interface{}{
"title": map[string]interface{}{"type": "text"},
},
},
},
}
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
// GetCreateStatement 使用 Indices.Get 方法,路径为 /<index>
if r.Method == http.MethodGet && r.URL.Path == "/test-index" && !strings.Contains(r.URL.Path, "_") {
writeJSON(w, indexDef)
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "test-index")
stmt, err := db.GetCreateStatement("test-index", "")
if err != nil {
t.Fatalf("GetCreateStatement 失败:%v", err)
}
if !strings.Contains(stmt, "test-index") {
t.Fatalf("CreateStatement 应包含索引名,实际:%s", stmt)
}
if !strings.Contains(stmt, "number_of_shards") {
t.Fatalf("CreateStatement 应包含 settings实际%s", stmt)
}
if !strings.Contains(stmt, "mappings") {
t.Fatalf("CreateStatement 应包含 mappings实际%s", stmt)
}
}
// TestElasticsearchGetIndexes 测试获取索引统计信息。
func TestElasticsearchGetIndexes(t *testing.T) {
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/_cat/indices") && r.Method == http.MethodGet {
writeJSON(w, []map[string]string{
{
"index": "test-index",
"health": "green",
"status": "open",
"docs.count": "1000",
"store.size": "5mb",
},
})
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestESDB(t, server.URL, "test-index")
indexes, err := db.GetIndexes("test-index", "")
if err != nil {
t.Fatalf("GetIndexes 失败:%v", err)
}
if len(indexes) != 1 {
t.Fatalf("期望 1 个索引信息,实际 %d", len(indexes))
}
idx := indexes[0]
if idx.Name != "test-index" {
t.Fatalf("索引名期望 test-index实际%s", idx.Name)
}
if idx.IndexType != "INDEX" {
t.Fatalf("索引类型期望 INDEX实际%s", idx.IndexType)
}
if !strings.Contains(idx.ColumnName, "green") {
t.Fatalf("索引信息应包含 health=green实际%s", idx.ColumnName)
}
if !strings.Contains(idx.ColumnName, "1000") {
t.Fatalf("索引信息应包含 docs=1000实际%s", idx.ColumnName)
}
}
// ---- 辅助函数测试 ----
// TestNormalizeElasticsearchConfig 测试配置规范化。
func TestNormalizeElasticsearchConfig(t *testing.T) {
t.Run("设置默认值", func(t *testing.T) {
config := normalizeElasticsearchConfig(connection.ConnectionConfig{
Type: "elasticsearch",
})
if config.Host != "localhost" {
t.Fatalf("默认 Host 期望 localhost实际%q", config.Host)
}
if config.Port != defaultEsPort {
t.Fatalf("默认 Port 期望 %d实际%d", defaultEsPort, config.Port)
}
if config.User != "" {
t.Fatalf("默认 User 期望空字符串,实际:%q", config.User)
}
})
t.Run("保留用户设置", func(t *testing.T) {
config := normalizeElasticsearchConfig(connection.ConnectionConfig{
Type: "elasticsearch",
Host: "es.example.com",
Port: 9201,
User: "admin",
Password: "secret",
})
if config.Host != "es.example.com" {
t.Fatalf("Host 期望 es.example.com实际%q", config.Host)
}
if config.Port != 9201 {
t.Fatalf("Port 期望 9201实际%d", config.Port)
}
if config.User != "admin" {
t.Fatalf("User 期望 admin实际%q", config.User)
}
})
t.Run("从 URI 中提取配置", func(t *testing.T) {
config := normalizeElasticsearchConfig(connection.ConnectionConfig{
Type: "elasticsearch",
URI: "http://uri-user:uri-pass@es-host:9202",
})
if config.User != "uri-user" {
t.Fatalf("User 期望从 URI 提取 uri-user实际%q", config.User)
}
if config.Password != "uri-pass" {
t.Fatalf("Password 期望从 URI 提取 uri-pass实际%q", config.Password)
}
if config.Host != "es-host" {
t.Fatalf("Host 期望从 URI 提取 es-host实际%q", config.Host)
}
if config.Port != 9202 {
t.Fatalf("Port 期望从 URI 提取 9202实际%d", config.Port)
}
})
t.Run("已有 Host 时不从 URI 覆盖", func(t *testing.T) {
config := normalizeElasticsearchConfig(connection.ConnectionConfig{
Type: "elasticsearch",
Host: "custom-host",
URI: "http://uri-user:uri-pass@uri-host:9200",
})
if config.Host != "custom-host" {
t.Fatalf("已有 Host 时不应覆盖,期望 custom-host实际%q", config.Host)
}
})
}
// TestApplyElasticsearchURI 测试 URI 解析。
func TestApplyElasticsearchURI(t *testing.T) {
t.Run("HTTPS URI 启用 SSL", func(t *testing.T) {
config := applyElasticsearchURI(connection.ConnectionConfig{
URI: "https://user:pass@es.example.com:9200",
})
if !config.UseSSL {
t.Fatal("HTTPS URI 应启用 SSL")
}
if config.SSLMode != "required" {
t.Fatalf("SSLMode 期望 required实际%q", config.SSLMode)
}
if config.Host != "es.example.com" {
t.Fatalf("Host 期望 es.example.com实际%q", config.Host)
}
})
t.Run("非 HTTP 协议忽略", func(t *testing.T) {
config := applyElasticsearchURI(connection.ConnectionConfig{
URI: "tcp://localhost:9200",
})
if config.Host != "" {
t.Fatalf("非 HTTP 协议不应设置 Host实际%q", config.Host)
}
})
t.Run("空 URI 不修改配置", func(t *testing.T) {
config := applyElasticsearchURI(connection.ConnectionConfig{
Host: "original-host",
Port: 9300,
})
if config.Host != "original-host" || config.Port != 9300 {
t.Fatal("空 URI 不应修改原有配置")
}
})
t.Run("已有用户凭证不被 URI 覆盖", func(t *testing.T) {
config := applyElasticsearchURI(connection.ConnectionConfig{
User: "existing-user",
Password: "existing-pass",
URI: "http://uri-user:uri-pass@localhost:9200",
})
if config.User != "existing-user" {
t.Fatalf("已有 User 不应覆盖,期望 existing-user实际%q", config.User)
}
if config.Password != "existing-pass" {
t.Fatalf("已有 Password 不应覆盖,期望 existing-pass实际%q", config.Password)
}
})
}
// TestExtractColumnsFromMapping 测试 mapping 字段提取。
func TestExtractColumnsFromMapping(t *testing.T) {
t.Run("标准字段提取", func(t *testing.T) {
mapping := map[string]interface{}{
"test-index": map[string]interface{}{
"mappings": map[string]interface{}{
"properties": map[string]interface{}{
"title": map[string]interface{}{"type": "text"},
"count": map[string]interface{}{"type": "long"},
"tags": map[string]interface{}{"type": "keyword"},
},
},
},
}
columns := extractColumnsFromMapping("test-index", mapping)
if len(columns) != 3 {
t.Fatalf("期望 3 个字段,实际 %d", len(columns))
}
typeMap := make(map[string]string)
for _, col := range columns {
typeMap[col.Name] = col.Type
}
expectedTypes := map[string]string{"title": "text", "count": "long", "tags": "keyword"}
for name, expectedType := range expectedTypes {
if typeMap[name] != expectedType {
t.Fatalf("字段 %q 类型期望 %q实际 %q", name, expectedType, typeMap[name])
}
}
})
t.Run("含 description 的字段提取注释", func(t *testing.T) {
mapping := map[string]interface{}{
"idx": map[string]interface{}{
"mappings": map[string]interface{}{
"properties": map[string]interface{}{
"email": map[string]interface{}{
"type": "keyword",
"description": "用户邮箱地址",
},
},
},
},
}
columns := extractColumnsFromMapping("idx", mapping)
if len(columns) != 1 {
t.Fatalf("期望 1 个字段,实际 %d", len(columns))
}
if columns[0].Comment != "用户邮箱地址" {
t.Fatalf("期望注释 '用户邮箱地址',实际:%q", columns[0].Comment)
}
})
t.Run("空 mapping 返回空列表", func(t *testing.T) {
mapping := map[string]interface{}{}
columns := extractColumnsFromMapping("non-existent", mapping)
if len(columns) != 0 {
t.Fatalf("空 mapping 应返回空列表,实际 %d 个", len(columns))
}
})
t.Run("索引数据无 mappings 字段", func(t *testing.T) {
mapping := map[string]interface{}{
"idx": map[string]interface{}{
"settings": map[string]interface{}{},
},
}
columns := extractColumnsFromMapping("idx", mapping)
if len(columns) != 0 {
t.Fatalf("无 mappings 时应返回空列表,实际 %d 个", len(columns))
}
})
t.Run("从 mapping 响应中自动查找索引数据", func(t *testing.T) {
// 模拟 ES 返回的 mapping 响应,键名不完全匹配(如带日期后缀的索引别名)
mapping := map[string]interface{}{
"logs-2024.01.01": map[string]interface{}{
"mappings": map[string]interface{}{
"properties": map[string]interface{}{
"message": map[string]interface{}{"type": "text"},
},
},
},
}
columns := extractColumnsFromMapping("non-matching-key", mapping)
if len(columns) != 1 {
t.Fatalf("应自动查找 mapping 数据,期望 1 个字段,实际 %d 个", len(columns))
}
})
}
// TestIsHiddenIndex 测试隐藏索引判断。
func TestIsHiddenIndex(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"隐藏索引 .security", ".security", true},
{"隐藏索引 .kibana_1", ".kibana_1", true},
{"普通索引 logs-2024", "logs-2024", false},
{"普通索引 users", "users", false},
{"空字符串", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isHiddenIndex(tt.input); got != tt.expected {
t.Fatalf("isHiddenIndex(%q) = %v期望 %v", tt.input, got, tt.expected)
}
})
}
}
// TestIsJSONDSL 测试 JSON DSL 检测。
func TestIsJSONDSL(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"JSON DSL", `{"query":{"match_all":{}}}`, true},
{"简单字符串", "hello world", false},
{"空字符串", "", false},
{"JSON 对象以空格开头", ` {"query":{}}`, false},
{"非查询 JSON 前缀", `[1,2,3]`, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isJSONDSL(tt.input); got != tt.expected {
t.Fatalf("isJSONDSL(%q) = %v期望 %v", tt.input, got, tt.expected)
}
})
}
}
// TestExtractEsFieldType 测试字段类型提取。
func TestExtractEsFieldType(t *testing.T) {
tests := []struct {
name string
prop interface{}
expected string
}{
{
name: "标准字段类型",
prop: map[string]interface{}{"type": "keyword"},
expected: "keyword",
},
{
name: "嵌套对象类型",
prop: map[string]interface{}{"properties": map[string]interface{}{}},
expected: "object",
},
{
name: "无 type 无 properties",
prop: map[string]interface{}{"enabled": true},
expected: "unknown",
},
{
name: "非 map 类型",
prop: "invalid",
expected: "unknown",
},
{
name: "nil 值",
prop: nil,
expected: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractEsFieldType(tt.prop); got != tt.expected {
t.Fatalf("extractEsFieldType() = %q期望 %q", got, tt.expected)
}
})
}
}
// TestBuildESClientConfig 测试 ES 客户端配置构建。
func TestBuildESClientConfig(t *testing.T) {
t.Run("HTTP 配置", func(t *testing.T) {
cfg := buildESClientConfig(connection.ConnectionConfig{
Host: "localhost",
Port: 9200,
User: "elastic",
})
if cfg.BaseURL != "http://localhost:9200" {
t.Fatalf("HTTP 地址期望 http://localhost:9200实际%v", cfg.BaseURL)
}
if cfg.Username != "elastic" {
t.Fatalf("用户名期望 elastic实际%q", cfg.Username)
}
})
t.Run("HTTPS 配置", func(t *testing.T) {
cfg := buildESClientConfig(connection.ConnectionConfig{
Host: "es.example.com",
Port: 9200,
UseSSL: true,
})
if cfg.BaseURL != "https://es.example.com:9200" {
t.Fatalf("HTTPS 地址期望 https://es.example.com:9200实际%v", cfg.BaseURL)
}
})
}
// TestResolveEsIndexName 测试索引名解析。
func TestResolveEsIndexName(t *testing.T) {
tests := []struct {
name string
dbName string
tableName string
defaultDB string
expected string
}{
{
name: "优先使用 tableName",
dbName: "db1",
tableName: "tbl1",
defaultDB: "default",
expected: "tbl1",
},
{
name: "回退到 dbName",
dbName: "db1",
tableName: "",
defaultDB: "default",
expected: "db1",
},
{
name: "回退到默认值",
dbName: "",
tableName: "",
defaultDB: "default",
expected: "default",
},
{
name: "全部为空",
dbName: "",
tableName: "",
defaultDB: "",
expected: "",
},
{
name: "空白字符等同于空",
dbName: " ",
tableName: " ",
defaultDB: "default",
expected: "default",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := resolveEsIndexName(tt.dbName, tt.tableName, tt.defaultDB)
if got != tt.expected {
t.Fatalf("resolveEsIndexName(%q, %q, %q) = %q期望 %q",
tt.dbName, tt.tableName, tt.defaultDB, got, tt.expected)
}
})
}
}
// TestESMockIntegration 使用完整 mock 服务器的集成测试。
func TestESMockIntegration(t *testing.T) {
// 构造完整的 mock mapping 响应
mappingData := buildMockESMappingResponse("products", map[string]string{
"name": "text",
"price": "float",
"in_stock": "boolean",
"created_at": "date",
"description": "text",
})
server := newMockESServer(t, func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
// Ping
case r.Method == http.MethodHead && path == "/":
w.WriteHeader(http.StatusOK)
// Cat Indices
case strings.HasPrefix(path, "/_cat/indices") && r.Method == http.MethodGet:
writeJSON(w, []map[string]string{
{"index": "products"},
{"index": "orders"},
{"index": ".internal"},
})
// Mapping
case strings.HasSuffix(path, "/_mapping"):
writeJSON(w, mappingData)
// GetCreateStatement
case r.Method == http.MethodGet && !strings.Contains(path, "_"):
writeJSON(w, map[string]interface{}{
"products": map[string]interface{}{
"settings": map[string]interface{}{"index": map[string]interface{}{"number_of_shards": "1"}},
"mappings": map[string]interface{}{"properties": map[string]interface{}{"name": map[string]interface{}{"type": "text"}}},
},
})
// Search
case r.Method == http.MethodPost && strings.HasSuffix(path, "/_search"):
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"hits": {
"total": {"value": 3},
"hits": [
{"_index": "products", "_id": "1", "_source": {"name": "商品A", "price": 99.9}},
{"_index": "products", "_id": "2", "_source": {"name": "商品B", "price": 199.9}},
{"_index": "products", "_id": "3", "_source": {"name": "商品C", "price": 299.9}}
]
}
}`))
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestESDB(t, server.URL, "products")
// 验证 Ping
if err := db.Ping(); err != nil {
t.Fatalf("Ping 失败:%v", err)
}
// 验证 GetDatabases应过滤 .internal
databases, err := db.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases 失败:%v", err)
}
slices.Sort(databases)
if len(databases) != 2 || databases[0] != "orders" || databases[1] != "products" {
t.Fatalf("GetDatabases 期望 [orders, products],实际:%v", databases)
}
// 验证 GetTables
tables, err := db.GetTables("")
if err != nil {
t.Fatalf("GetTables 失败:%v", err)
}
if len(tables) != 1 || tables[0] != "products" {
t.Fatalf("GetTables 期望 [products],实际:%v", tables)
}
// 验证 GetColumns
columns, err := db.GetColumns("products", "")
if err != nil {
t.Fatalf("GetColumns 失败:%v", err)
}
if len(columns) != 5 {
t.Fatalf("GetColumns 期望 5 个字段,实际 %d", len(columns))
}
// 验证 DSL 查询
rows, _, err := db.Query(`{"query":{"match_all":{}}}`)
if err != nil {
t.Fatalf("DSL 查询失败:%v", err)
}
if len(rows) != 3 {
t.Fatalf("DSL 查询期望 3 条结果,实际 %d", len(rows))
}
// 验证 query_string 查询
rows, _, err = db.Query("商品")
if err != nil {
t.Fatalf("query_string 查询失败:%v", err)
}
if len(rows) != 3 {
t.Fatalf("query_string 查询期望 3 条结果,实际 %d", len(rows))
}
// 验证 GetCreateStatement
stmt, err := db.GetCreateStatement("products", "")
if err != nil {
t.Fatalf("GetCreateStatement 失败:%v", err)
}
if !strings.Contains(stmt, "products") {
t.Fatalf("GetCreateStatement 应包含索引名,实际:%s", stmt)
}
// 验证 Exec 不支持
_, err = db.Exec("DELETE products")
if err == nil || !strings.Contains(err.Error(), "不支持") {
t.Fatalf("Exec 应返回不支持错误,实际:%v", err)
}
// 验证 GetForeignKeys / GetTriggers 返回空
fks, _ := db.GetForeignKeys("products", "")
if len(fks) != 0 {
t.Fatalf("GetForeignKeys 应返回空,实际:%d", len(fks))
}
triggers, _ := db.GetTriggers("products", "")
if len(triggers) != 0 {
t.Fatalf("GetTriggers 应返回空,实际:%d", len(triggers))
}
}