mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 00:31:35 +00:00
- Implement full-text search service with Weaviate integration - Remove Bleve search implementation - Add GraphQL schema files for search, work, author, and translation - Refactor search domain interfaces - Update Weaviate wrapper with integration tests - Clean up unused search client files
331 lines
9.6 KiB
Go
331 lines
9.6 KiB
Go
package search_test
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"testing"
|
|
"tercul/internal/domain"
|
|
domainsearch "tercul/internal/domain/search"
|
|
"tercul/internal/platform/search"
|
|
"time"
|
|
|
|
"github.com/go-openapi/strfmt"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
"github.com/testcontainers/testcontainers-go"
|
|
"github.com/testcontainers/testcontainers-go/wait"
|
|
"github.com/weaviate/weaviate-go-client/v5/weaviate"
|
|
"github.com/weaviate/weaviate/entities/models"
|
|
)
|
|
|
|
type WeaviateWrapperIntegrationTestSuite struct {
|
|
suite.Suite
|
|
client *weaviate.Client
|
|
wrapper domainsearch.SearchClient
|
|
weaviateContainer testcontainers.Container
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) SetupSuite() {
|
|
ctx := context.Background()
|
|
|
|
req := testcontainers.ContainerRequest{
|
|
Image: "cr.weaviate.io/semitechnologies/weaviate:1.24.1",
|
|
ExposedPorts: []string{"8080/tcp"},
|
|
Env: map[string]string{
|
|
"AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED": "true",
|
|
"DEFAULT_VECTORIZER_MODULE": "none",
|
|
"PERSISTENCE_DATA_PATH": "/var/lib/weaviate",
|
|
},
|
|
WaitingFor: wait.ForHTTP("/v1/.well-known/ready").WithPort("8080"),
|
|
}
|
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
|
ContainerRequest: req,
|
|
Started: true,
|
|
})
|
|
require.NoError(s.T(), err)
|
|
s.weaviateContainer = container
|
|
|
|
host, err := container.Host(ctx)
|
|
require.NoError(s.T(), err)
|
|
port, err := container.MappedPort(ctx, "8080")
|
|
require.NoError(s.T(), err)
|
|
|
|
cfg := weaviate.Config{
|
|
Host: fmt.Sprintf("%s:%s", host, port.Port()),
|
|
Scheme: "http",
|
|
}
|
|
client, err := weaviate.NewClient(cfg)
|
|
require.NoError(s.T(), err)
|
|
s.client = client
|
|
|
|
s.wrapper = search.NewWeaviateWrapper(client)
|
|
|
|
s.createTestSchema(ctx)
|
|
s.seedTestData(ctx)
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) TearDownSuite() {
|
|
ctx := context.Background()
|
|
err := s.client.Schema().AllDeleter().Do(ctx)
|
|
require.NoError(s.T(), err)
|
|
require.NoError(s.T(), s.weaviateContainer.Terminate(ctx))
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) createTestSchema(ctx context.Context) {
|
|
workClass := &models.Class{
|
|
Class: "Work",
|
|
Properties: []*models.Property{
|
|
{Name: "db_id", DataType: []string{"int"}},
|
|
{Name: "title", DataType: []string{"text"}},
|
|
{Name: "description", DataType: []string{"text"}},
|
|
{Name: "language", DataType: []string{"string"}},
|
|
{Name: "status", DataType: []string{"string"}},
|
|
{Name: "createdAt", DataType: []string{"date"}},
|
|
{Name: "updatedAt", DataType: []string{"date"}},
|
|
{Name: "tags", DataType: []string{"string[]"}},
|
|
},
|
|
}
|
|
err := s.client.Schema().ClassCreator().WithClass(workClass).Do(ctx)
|
|
require.NoError(s.T(), err)
|
|
|
|
translationClass := &models.Class{
|
|
Class: "Translation",
|
|
Properties: []*models.Property{
|
|
{Name: "db_id", DataType: []string{"int"}},
|
|
{Name: "title", DataType: []string{"text"}},
|
|
{Name: "content", DataType: []string{"text"}},
|
|
{Name: "language", DataType: []string{"string"}},
|
|
{Name: "status", DataType: []string{"string"}},
|
|
},
|
|
}
|
|
err = s.client.Schema().ClassCreator().WithClass(translationClass).Do(ctx)
|
|
require.NoError(s.T(), err)
|
|
|
|
authorClass := &models.Class{
|
|
Class: "Author",
|
|
Properties: []*models.Property{
|
|
{Name: "db_id", DataType: []string{"int"}},
|
|
{Name: "name", DataType: []string{"text"}},
|
|
{Name: "biography", DataType: []string{"text"}},
|
|
},
|
|
}
|
|
err = s.client.Schema().ClassCreator().WithClass(authorClass).Do(ctx)
|
|
require.NoError(s.T(), err)
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) seedTestData(ctx context.Context) {
|
|
objects := []*models.Object{
|
|
{
|
|
Class: "Work",
|
|
ID: s.getTestUUID("work-1"),
|
|
Properties: map[string]interface{}{
|
|
"db_id": 1,
|
|
"title": "The Great Gatsby",
|
|
"description": "A novel by F. Scott Fitzgerald",
|
|
"language": "en",
|
|
"status": "published",
|
|
"createdAt": time.Now().Add(-24 * time.Hour).Format(time.RFC3339),
|
|
"updatedAt": time.Now().Format(time.RFC3339),
|
|
"tags": []string{"classic", "american"},
|
|
},
|
|
},
|
|
{
|
|
Class: "Work",
|
|
ID: s.getTestUUID("work-2"),
|
|
Properties: map[string]interface{}{
|
|
"db_id": 2,
|
|
"title": "War and Peace",
|
|
"description": "A classic novel by Leo Tolstoy",
|
|
"language": "ru",
|
|
"status": "published",
|
|
"createdAt": time.Now().Add(-48 * time.Hour).Format(time.RFC3339),
|
|
"updatedAt": time.Now().Format(time.RFC3339),
|
|
"tags": []string{"classic", "russian"},
|
|
},
|
|
},
|
|
{
|
|
Class: "Translation",
|
|
ID: s.getTestUUID("translation-101"),
|
|
Properties: map[string]interface{}{
|
|
"db_id": 101,
|
|
"title": "Великий Гэтсби",
|
|
"content": "Содержание перевода...",
|
|
"language": "ru",
|
|
"status": "published",
|
|
},
|
|
},
|
|
{
|
|
Class: "Author",
|
|
ID: s.getTestUUID("author-201"),
|
|
Properties: map[string]interface{}{
|
|
"db_id": 201,
|
|
"name": "F. Scott Fitzgerald",
|
|
"biography": "A writer of many a novel.",
|
|
},
|
|
},
|
|
}
|
|
|
|
_, err := s.client.Batch().ObjectsBatcher().WithObjects(objects...).Do(ctx)
|
|
require.NoError(s.T(), err)
|
|
time.Sleep(1 * time.Second) // Give Weaviate a moment to index
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) TestSearch_AllEntities() {
|
|
params := domainsearch.SearchParams{
|
|
Query: "novel",
|
|
Mode: domainsearch.SearchModeBM25,
|
|
Limit: 10,
|
|
Offset: 0,
|
|
}
|
|
|
|
results, err := s.wrapper.Search(context.Background(), params)
|
|
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(results)
|
|
s.Require().Equal(int64(3), results.TotalResults) // Both works and the author's bio contain "novel"
|
|
s.Require().Len(results.Results, 3)
|
|
|
|
// Check if the correct types are returned
|
|
foundWork := 0
|
|
foundAuthor := 0
|
|
for _, item := range results.Results {
|
|
if item.Type == "Work" {
|
|
foundWork++
|
|
}
|
|
if item.Type == "Author" {
|
|
foundAuthor++
|
|
}
|
|
}
|
|
s.Require().Equal(2, foundWork, "Expected to find two works")
|
|
s.Require().Equal(1, foundAuthor, "Expected to find one author")
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) TestSearch_WithLanguageFilter() {
|
|
params := domainsearch.SearchParams{
|
|
Query: "novel",
|
|
Mode: domainsearch.SearchModeBM25,
|
|
Limit: 10,
|
|
Offset: 0,
|
|
Filters: domainsearch.SearchFilters{
|
|
Languages: []string{"ru"},
|
|
},
|
|
}
|
|
|
|
results, err := s.wrapper.Search(context.Background(), params)
|
|
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(results)
|
|
s.Require().Equal(int64(1), results.TotalResults, "Expected only 'War and Peace' to match")
|
|
s.Require().Len(results.Results, 1)
|
|
s.Require().Equal("Work", results.Results[0].Type)
|
|
work, ok := results.Results[0].Entity.(domain.Work)
|
|
s.Require().True(ok)
|
|
s.Require().Equal("War and Peace", work.Title)
|
|
s.Require().Equal(uint(2), work.ID)
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) TestSearch_WithTagFilter() {
|
|
params := domainsearch.SearchParams{
|
|
Query: "", // No query, just filtering
|
|
Mode: domainsearch.SearchModeBM25,
|
|
Limit: 10,
|
|
Offset: 0,
|
|
Filters: domainsearch.SearchFilters{
|
|
Types: []string{"Work"},
|
|
Tags: []string{"american"},
|
|
},
|
|
}
|
|
|
|
results, err := s.wrapper.Search(context.Background(), params)
|
|
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(results)
|
|
s.Require().Equal(int64(1), results.TotalResults, "Expected only 'The Great Gatsby' to match")
|
|
s.Require().Len(results.Results, 1)
|
|
s.Require().Equal("Work", results.Results[0].Type)
|
|
work, ok := results.Results[0].Entity.(domain.Work)
|
|
s.Require().True(ok)
|
|
s.Require().Equal("The Great Gatsby", work.Title)
|
|
s.Require().Equal(uint(1), work.ID)
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) TestSearch_WithAuthorFilter() {
|
|
params := domainsearch.SearchParams{
|
|
Query: "", // No query, just filtering
|
|
Mode: domainsearch.SearchModeBM25,
|
|
Limit: 10,
|
|
Offset: 0,
|
|
Filters: domainsearch.SearchFilters{
|
|
Types: []string{"Author"},
|
|
Authors: []string{"F. Scott Fitzgerald"},
|
|
},
|
|
}
|
|
|
|
results, err := s.wrapper.Search(context.Background(), params)
|
|
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(results)
|
|
s.Require().Equal(int64(1), results.TotalResults)
|
|
s.Require().Len(results.Results, 1)
|
|
s.Require().Equal("Author", results.Results[0].Type)
|
|
author, ok := results.Results[0].Entity.(domain.Author)
|
|
s.Require().True(ok)
|
|
s.Require().Equal("F. Scott Fitzgerald", author.Name)
|
|
s.Require().Equal(uint(201), author.ID)
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) TestSearch_Pagination() {
|
|
params := domainsearch.SearchParams{
|
|
Query: "novel",
|
|
Mode: domainsearch.SearchModeBM25,
|
|
Limit: 1,
|
|
Offset: 0,
|
|
}
|
|
|
|
// First page
|
|
results1, err := s.wrapper.Search(context.Background(), params)
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(results1)
|
|
s.Require().Equal(int64(3), results1.TotalResults)
|
|
s.Require().Len(results1.Results, 1)
|
|
|
|
var firstID uint
|
|
switch e := results1.Results[0].Entity.(type) {
|
|
case domain.Work:
|
|
firstID = e.ID
|
|
case domain.Author:
|
|
firstID = e.ID
|
|
default:
|
|
s.T().Fatalf("Unexpected entity type: %T", e)
|
|
}
|
|
|
|
// Second page
|
|
params.Offset = 1
|
|
results2, err := s.wrapper.Search(context.Background(), params)
|
|
s.Require().NoError(err)
|
|
s.Require().NotNil(results2)
|
|
s.Require().Equal(int64(3), results2.TotalResults)
|
|
s.Require().Len(results2.Results, 1)
|
|
|
|
var secondID uint
|
|
switch e := results2.Results[0].Entity.(type) {
|
|
case domain.Work:
|
|
secondID = e.ID
|
|
case domain.Author:
|
|
secondID = e.ID
|
|
default:
|
|
s.T().Fatalf("Unexpected entity type: %T", e)
|
|
}
|
|
|
|
s.Require().NotEqual(firstID, secondID, "Paginated results should be different")
|
|
}
|
|
|
|
func TestWeaviateWrapperIntegrationTestSuite(t *testing.T) {
|
|
suite.Run(t, new(WeaviateWrapperIntegrationTestSuite))
|
|
}
|
|
|
|
func (s *WeaviateWrapperIntegrationTestSuite) getTestUUID(input string) strfmt.UUID {
|
|
return strfmt.UUID(uuid.NewSHA1(uuid.Nil, []byte(input)).String())
|
|
}
|