diff --git a/cmd/api/main.go b/cmd/api/main.go index 881c7d4..2588088 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -9,9 +9,10 @@ import ( "runtime" "syscall" "tercul/internal/app" + "tercul/internal/app/analytics" graph "tercul/internal/adapters/graphql" - "tercul/internal/application/services" dbsql "tercul/internal/data/sql" + "tercul/internal/jobs/linguistics" "tercul/internal/platform/auth" "tercul/internal/platform/config" "tercul/internal/platform/db" @@ -86,23 +87,22 @@ func main() { // Create repositories repos := dbsql.NewRepositories(database) + // Create linguistics dependencies + analysisRepo := linguistics.NewGORMAnalysisRepository(database) + sentimentProvider, err := linguistics.NewGoVADERSentimentProvider() + if err != nil { + log.LogFatal("Failed to create sentiment provider", log.F("error", err)) + } // Create application services - analyticsSvc := services.NewAnalyticsService( - repos.Work, - repos.Translation, - repos.Author, - repos.User, - repos.Like, - ) + analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider) // Create application - application := app.NewApplication(repos, searchClient, nil) // Analytics service is now separate + application := app.NewApplication(repos, searchClient, analyticsService) // Create GraphQL server resolver := &graph.Resolver{ - App: application, - AnalyticsService: analyticsSvc, + App: application, } jwtManager := auth.NewJWTManager() diff --git a/go.mod b/go.mod index c581e69..06fecca 100644 --- a/go.mod +++ b/go.mod @@ -25,6 +25,7 @@ require ( ) require ( + ariga.io/atlas-go-sdk v0.5.1 // indirect filippo.io/edwards25519 v1.1.0 // indirect github.com/ClickHouse/ch-go v0.67.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.40.1 // indirect diff --git a/go.sum b/go.sum index e255f94..01c4c9f 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +ariga.io/atlas-go-sdk v0.5.1 h1:I3iRshdwSODVWwMS4zvXObnfCQrEOY8BLRwynJQA+qE= +ariga.io/atlas-go-sdk v0.5.1/go.mod h1:UZXG++2NQCDAetk+oIitYIGpL/VsBVCt4GXbtWBA/GY= cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= diff --git a/internal/adapters/graphql/generated.go b/internal/adapters/graphql/generated.go index 67c537b..af9d3f9 100644 --- a/internal/adapters/graphql/generated.go +++ b/internal/adapters/graphql/generated.go @@ -59,14 +59,6 @@ type ComplexityRoot struct { Users func(childComplexity int) int } - Analytics struct { - TotalAuthors func(childComplexity int) int - TotalLikes func(childComplexity int) int - TotalTranslations func(childComplexity int) int - TotalUsers func(childComplexity int) int - TotalWorks func(childComplexity int) int - } - AuthPayload struct { Token func(childComplexity int) int User func(childComplexity int) int @@ -341,7 +333,6 @@ type ComplexityRoot struct { } Query struct { - Analytics func(childComplexity int) int Author func(childComplexity int, id string) int Authors func(childComplexity int, limit *int32, offset *int32, search *string, countryID *string) int Categories func(childComplexity int, limit *int32, offset *int32) int @@ -625,7 +616,6 @@ type QueryResolver interface { Comments(ctx context.Context, workID *string, translationID *string, userID *string, limit *int32, offset *int32) ([]*model.Comment, error) Search(ctx context.Context, query string, limit *int32, offset *int32, filters *model.SearchFilters) (*model.SearchResults, error) TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) - Analytics(ctx context.Context) (*model.Analytics, error) } type executableSchema struct { @@ -703,41 +693,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.Address.Users(childComplexity), true - case "Analytics.totalAuthors": - if e.complexity.Analytics.TotalAuthors == nil { - break - } - - return e.complexity.Analytics.TotalAuthors(childComplexity), true - - case "Analytics.totalLikes": - if e.complexity.Analytics.TotalLikes == nil { - break - } - - return e.complexity.Analytics.TotalLikes(childComplexity), true - - case "Analytics.totalTranslations": - if e.complexity.Analytics.TotalTranslations == nil { - break - } - - return e.complexity.Analytics.TotalTranslations(childComplexity), true - - case "Analytics.totalUsers": - if e.complexity.Analytics.TotalUsers == nil { - break - } - - return e.complexity.Analytics.TotalUsers(childComplexity), true - - case "Analytics.totalWorks": - if e.complexity.Analytics.TotalWorks == nil { - break - } - - return e.complexity.Analytics.TotalWorks(childComplexity), true - case "AuthPayload.token": if e.complexity.AuthPayload.Token == nil { break @@ -2341,13 +2296,6 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.PoeticAnalysis.Work(childComplexity), true - case "Query.analytics": - if e.complexity.Query.Analytics == nil { - break - } - - return e.complexity.Query.Analytics(childComplexity), true - case "Query.author": if e.complexity.Query.Author == nil { break @@ -5143,226 +5091,6 @@ func (ec *executionContext) fieldContext_Address_users(_ context.Context, field return fc, nil } -func (ec *executionContext) _Analytics_totalWorks(ctx context.Context, field graphql.CollectedField, obj *model.Analytics) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Analytics_totalWorks(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.TotalWorks, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(int32) - fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Analytics_totalWorks(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Analytics", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _Analytics_totalTranslations(ctx context.Context, field graphql.CollectedField, obj *model.Analytics) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Analytics_totalTranslations(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.TotalTranslations, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(int32) - fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Analytics_totalTranslations(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Analytics", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _Analytics_totalAuthors(ctx context.Context, field graphql.CollectedField, obj *model.Analytics) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Analytics_totalAuthors(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.TotalAuthors, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(int32) - fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Analytics_totalAuthors(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Analytics", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _Analytics_totalUsers(ctx context.Context, field graphql.CollectedField, obj *model.Analytics) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Analytics_totalUsers(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.TotalUsers, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(int32) - fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Analytics_totalUsers(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Analytics", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") - }, - } - return fc, nil -} - -func (ec *executionContext) _Analytics_totalLikes(ctx context.Context, field graphql.CollectedField, obj *model.Analytics) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Analytics_totalLikes(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return obj.TotalLikes, nil - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(int32) - fc.Result = res - return ec.marshalNInt2int32(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Analytics_totalLikes(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Analytics", - Field: field, - IsMethod: false, - IsResolver: false, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - return nil, errors.New("field of type Int does not have child fields") - }, - } - return fc, nil -} - func (ec *executionContext) _AuthPayload_token(ctx context.Context, field graphql.CollectedField, obj *model.AuthPayload) (ret graphql.Marshaler) { fc, err := ec.fieldContext_AuthPayload_token(ctx, field) if err != nil { @@ -19091,62 +18819,6 @@ func (ec *executionContext) fieldContext_Query_trendingWorks(ctx context.Context return fc, nil } -func (ec *executionContext) _Query_analytics(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { - fc, err := ec.fieldContext_Query_analytics(ctx, field) - if err != nil { - return graphql.Null - } - ctx = graphql.WithFieldContext(ctx, fc) - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - ret = graphql.Null - } - }() - resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) { - ctx = rctx // use context from middleware stack in children - return ec.resolvers.Query().Analytics(rctx) - }) - if err != nil { - ec.Error(ctx, err) - return graphql.Null - } - if resTmp == nil { - if !graphql.HasFieldError(ctx, fc) { - ec.Errorf(ctx, "must not be null") - } - return graphql.Null - } - res := resTmp.(*model.Analytics) - fc.Result = res - return ec.marshalNAnalytics2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAnalytics(ctx, field.Selections, res) -} - -func (ec *executionContext) fieldContext_Query_analytics(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { - fc = &graphql.FieldContext{ - Object: "Query", - Field: field, - IsMethod: true, - IsResolver: true, - Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { - switch field.Name { - case "totalWorks": - return ec.fieldContext_Analytics_totalWorks(ctx, field) - case "totalTranslations": - return ec.fieldContext_Analytics_totalTranslations(ctx, field) - case "totalAuthors": - return ec.fieldContext_Analytics_totalAuthors(ctx, field) - case "totalUsers": - return ec.fieldContext_Analytics_totalUsers(ctx, field) - case "totalLikes": - return ec.fieldContext_Analytics_totalLikes(ctx, field) - } - return nil, fmt.Errorf("no field named %q was found under type Analytics", field.Name) - }, - } - return fc, nil -} - func (ec *executionContext) _Query___type(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Query___type(ctx, field) if err != nil { @@ -29888,65 +29560,6 @@ func (ec *executionContext) _Address(ctx context.Context, sel ast.SelectionSet, return out } -var analyticsImplementors = []string{"Analytics"} - -func (ec *executionContext) _Analytics(ctx context.Context, sel ast.SelectionSet, obj *model.Analytics) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, analyticsImplementors) - - out := graphql.NewFieldSet(fields) - deferred := make(map[string]*graphql.FieldSet) - for i, field := range fields { - switch field.Name { - case "__typename": - out.Values[i] = graphql.MarshalString("Analytics") - case "totalWorks": - out.Values[i] = ec._Analytics_totalWorks(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - case "totalTranslations": - out.Values[i] = ec._Analytics_totalTranslations(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - case "totalAuthors": - out.Values[i] = ec._Analytics_totalAuthors(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - case "totalUsers": - out.Values[i] = ec._Analytics_totalUsers(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - case "totalLikes": - out.Values[i] = ec._Analytics_totalLikes(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ - } - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } - - atomic.AddInt32(&ec.deferred, int32(len(deferred))) - - for label, dfs := range deferred { - ec.processDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } - - return out -} - var authPayloadImplementors = []string{"AuthPayload"} func (ec *executionContext) _AuthPayload(ctx context.Context, sel ast.SelectionSet, obj *model.AuthPayload) graphql.Marshaler { @@ -32117,28 +31730,6 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) - case "analytics": - field := field - - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._Query_analytics(ctx, field) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } - return res - } - - rrm := func(ctx context.Context) graphql.Marshaler { - return ec.OperationContext.RootResolverMiddleware(ctx, - func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - } - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return rrm(innerCtx) }) case "__type": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { @@ -33543,20 +33134,6 @@ func (ec *executionContext) ___Type(ctx context.Context, sel ast.SelectionSet, o // region ***************************** type.gotpl ***************************** -func (ec *executionContext) marshalNAnalytics2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAnalytics(ctx context.Context, sel ast.SelectionSet, v model.Analytics) graphql.Marshaler { - return ec._Analytics(ctx, sel, &v) -} - -func (ec *executionContext) marshalNAnalytics2ᚖterculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAnalytics(ctx context.Context, sel ast.SelectionSet, v *model.Analytics) graphql.Marshaler { - if v == nil { - if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { - ec.Errorf(ctx, "the requested element is null which the schema does not allow") - } - return graphql.Null - } - return ec._Analytics(ctx, sel, v) -} - func (ec *executionContext) marshalNAuthPayload2terculᚋinternalᚋadaptersᚋgraphqlᚋmodelᚐAuthPayload(ctx context.Context, sel ast.SelectionSet, v model.AuthPayload) graphql.Marshaler { return ec._AuthPayload(ctx, sel, &v) } diff --git a/internal/adapters/graphql/graphql_test_utils_test.go b/internal/adapters/graphql/graphql_test_utils_test.go new file mode 100644 index 0000000..7f31df8 --- /dev/null +++ b/internal/adapters/graphql/graphql_test_utils_test.go @@ -0,0 +1,97 @@ +package graphql_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" +) + +// GraphQLRequest represents a GraphQL request +type GraphQLRequest struct { + Query string `json:"query"` + OperationName string `json:"operationName,omitempty"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +// GraphQLResponse represents a generic GraphQL response +type GraphQLResponse[T any] struct { + Data T `json:"data,omitempty"` + Errors []map[string]interface{} `json:"errors,omitempty"` +} + +// graphQLTestServer defines the interface for a test server that can execute GraphQL requests. +type graphQLTestServer interface { + getURL() string + getClient() *http.Client +} + +// executeGraphQL executes a GraphQL query against a test server and decodes the response. +func executeGraphQL[T any](s graphQLTestServer, query string, variables map[string]interface{}, token *string) (*GraphQLResponse[T], error) { + request := GraphQLRequest{ + Query: query, + Variables: variables, + } + + requestBody, err := json.Marshal(request) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", s.getURL(), bytes.NewBuffer(requestBody)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + if token != nil { + req.Header.Set("Authorization", "Bearer "+*token) + } + + resp, err := s.getClient().Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var response GraphQLResponse[T] + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, err + } + + return &response, nil +} + +// Implement the graphQLTestServer interface for GraphQLIntegrationSuite +func (s *GraphQLIntegrationSuite) getURL() string { + return s.server.URL +} + +func (s *GraphQLIntegrationSuite) getClient() *http.Client { + return s.client +} + +// MockGraphQLServer provides a mock server for unit tests that don't require the full integration suite. +type MockGraphQLServer struct { + Server *httptest.Server + Client *http.Client +} + +func NewMockGraphQLServer(h http.Handler) *MockGraphQLServer { + ts := httptest.NewServer(h) + return &MockGraphQLServer{ + Server: ts, + Client: ts.Client(), + } +} + +func (s *MockGraphQLServer) getURL() string { + return s.Server.URL +} + +func (s *MockGraphQLServer) getClient() *http.Client { + return s.Client +} + +func (s *MockGraphQLServer) Close() { + s.Server.Close() +} \ No newline at end of file diff --git a/internal/adapters/graphql/integration_test.go b/internal/adapters/graphql/integration_test.go index e9a3b2d..62f9e73 100644 --- a/internal/adapters/graphql/integration_test.go +++ b/internal/adapters/graphql/integration_test.go @@ -1,9 +1,7 @@ package graphql_test import ( - "bytes" "context" - "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -12,7 +10,6 @@ import ( graph "tercul/internal/adapters/graphql" "tercul/internal/app/auth" - "tercul/internal/application/services" "tercul/internal/app/author" "tercul/internal/app/bookmark" "tercul/internal/app/collection" @@ -27,19 +24,6 @@ import ( "github.com/stretchr/testify/suite" ) -// GraphQLRequest represents a GraphQL request -type GraphQLRequest struct { - Query string `json:"query"` - OperationName string `json:"operationName,omitempty"` - Variables map[string]interface{} `json:"variables,omitempty"` -} - -// GraphQLResponse represents a generic GraphQL response -type GraphQLResponse[T any] struct { - Data T `json:"data,omitempty"` - Errors []map[string]interface{} `json:"errors,omitempty"` -} - // GraphQLIntegrationSuite is a test suite for GraphQL integration tests type GraphQLIntegrationSuite struct { testutil.IntegrationTestSuite @@ -77,14 +61,8 @@ func (s *GraphQLIntegrationSuite) CreateAuthenticatedUser(username, email string func (s *GraphQLIntegrationSuite) SetupSuite() { s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) - // Create analytics service - analyticsSvc := services.NewAnalyticsService(s.Repos.Work, s.Repos.Translation, s.Repos.Author, s.Repos.User, s.Repos.Like) - // Create GraphQL server with the test resolver - resolver := &graph.Resolver{ - App: s.App, - AnalyticsService: analyticsSvc, - } + resolver := &graph.Resolver{App: s.App} srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver})) // Create JWT manager and middleware @@ -110,47 +88,6 @@ func (s *GraphQLIntegrationSuite) SetupTest() { s.DB.Exec("DELETE FROM trendings") } -// executeGraphQL executes a GraphQL query and decodes the response into a generic type -func executeGraphQL[T any](s *GraphQLIntegrationSuite, query string, variables map[string]interface{}, token *string) (*GraphQLResponse[T], error) { - // Create the request - request := GraphQLRequest{ - Query: query, - Variables: variables, - } - - // Marshal the request to JSON - requestBody, err := json.Marshal(request) - if err != nil { - return nil, err - } - - // Create an HTTP request - req, err := http.NewRequest("POST", s.server.URL, bytes.NewBuffer(requestBody)) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/json") - if token != nil { - req.Header.Set("Authorization", "Bearer "+*token) - } - - // Execute the request - resp, err := s.client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - - // Parse the response - var response GraphQLResponse[T] - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return nil, err - } - - return &response, nil -} - type GetWorkResponse struct { Work struct { ID string `json:"id"` @@ -1024,6 +961,34 @@ type TrendingWorksResponse struct { } `json:"trendingWorks"` } +func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() { + s.Run("should return a list of trending works", func() { + // Arrange + work1 := s.CreateTestWork("Work 1", "en", "content") + work2 := s.CreateTestWork("Work 2", "en", "content") + s.DB.Create(&domain.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1}) + s.DB.Create(&domain.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10}) + s.Require().NoError(s.App.Analytics.UpdateTrending(context.Background())) + + // Act + query := ` + query GetTrendingWorks { + trendingWorks { + id + name + } + } + ` + response, err := executeGraphQL[TrendingWorksResponse](s, query, nil, nil) + s.Require().NoError(err) + s.Require().NotNil(response) + s.Require().Nil(response.Errors, "GraphQL query should not return errors") + + // Assert + s.Len(response.Data.TrendingWorks, 2) + s.Equal(fmt.Sprintf("%d", work2.ID), response.Data.TrendingWorks[0].ID) + }) +} func (s *GraphQLIntegrationSuite) TestCollectionMutations() { // Create users for testing authorization diff --git a/internal/adapters/graphql/like_resolvers_unit_test.go b/internal/adapters/graphql/like_resolvers_unit_test.go new file mode 100644 index 0000000..469ec25 --- /dev/null +++ b/internal/adapters/graphql/like_resolvers_unit_test.go @@ -0,0 +1,120 @@ +package graphql_test + +import ( + "context" + "fmt" + "strconv" + "testing" + + "tercul/internal/adapters/graphql" + "tercul/internal/adapters/graphql/model" + "tercul/internal/app" + "tercul/internal/app/analytics" + "tercul/internal/app/like" + "tercul/internal/domain" + platform_auth "tercul/internal/platform/auth" + "tercul/internal/testutil" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/suite" +) + +// LikeResolversUnitSuite is a unit test suite for the like resolvers. +type LikeResolversUnitSuite struct { + suite.Suite + resolver *graphql.Resolver + mockLikeRepo *testutil.MockLikeRepository + mockWorkRepo *testutil.MockWorkRepository + mockAnalyticsSvc *testutil.MockAnalyticsService +} + +func (s *LikeResolversUnitSuite) SetupTest() { + // 1. Create mock repositories + s.mockLikeRepo = new(testutil.MockLikeRepository) + s.mockWorkRepo = new(testutil.MockWorkRepository) + s.mockAnalyticsSvc = new(testutil.MockAnalyticsService) + + // 2. Create real services with mock repositories + likeService := like.NewService(s.mockLikeRepo) + analyticsService := analytics.NewService(s.mockAnalyticsSvc, nil, nil, nil, nil) + + // 3. Create the resolver with the services + s.resolver = &graphql.Resolver{ + App: &app.Application{ + Like: likeService, + Analytics: analyticsService, + }, + } +} + +func TestLikeResolversUnitSuite(t *testing.T) { + suite.Run(t, new(LikeResolversUnitSuite)) +} + +func (s *LikeResolversUnitSuite) TestCreateLike() { + // 1. Setup + workIDStr := "1" + workIDUint64, _ := strconv.ParseUint(workIDStr, 10, 32) + workIDUint := uint(workIDUint64) + userID := uint(123) + + // Mock repository responses + s.mockWorkRepo.On("Exists", mock.Anything, workIDUint).Return(true, nil) + s.mockLikeRepo.On("Create", mock.Anything, mock.AnythingOfType("*domain.Like")).Run(func(args mock.Arguments) { + arg := args.Get(1).(*domain.Like) + arg.ID = 1 // Simulate database assigning an ID + }).Return(nil) + s.mockAnalyticsSvc.On("IncrementWorkCounter", mock.Anything, workIDUint, "likes", 1).Return(nil) + + // Create a context with an authenticated user + ctx := platform_auth.ContextWithUserID(context.Background(), userID) + + // 2. Execution + likeInput := model.LikeInput{ + WorkID: &workIDStr, + } + createdLike, err := s.resolver.Mutation().CreateLike(ctx, likeInput) + + // 3. Assertions + s.Require().NoError(err) + s.Require().NotNil(createdLike) + + s.Equal("1", createdLike.ID) + s.Equal(fmt.Sprintf("%d", userID), createdLike.User.ID) + + // Verify that the repository's Create method was called + s.mockLikeRepo.AssertCalled(s.T(), "Create", mock.Anything, mock.MatchedBy(func(l *domain.Like) bool { + return *l.WorkID == workIDUint && l.UserID == userID + })) + // Verify that analytics was called + s.mockAnalyticsSvc.AssertCalled(s.T(), "IncrementWorkCounter", mock.Anything, workIDUint, "likes", 1) +} + +func (s *LikeResolversUnitSuite) TestDeleteLike() { + // 1. Setup + likeIDStr := "1" + likeIDUint, _ := strconv.ParseUint(likeIDStr, 10, 32) + userID := uint(123) + + // Mock the repository response for the initial 'find' + s.mockLikeRepo.On("GetByID", mock.Anything, uint(likeIDUint)).Return(&domain.Like{ + BaseModel: domain.BaseModel{ID: uint(likeIDUint)}, + UserID: userID, + }, nil) + + // Mock the repository response for the 'delete' + s.mockLikeRepo.On("Delete", mock.Anything, uint(likeIDUint)).Return(nil) + + // Create a context with an authenticated user + ctx := platform_auth.ContextWithUserID(context.Background(), userID) + + // 2. Execution + deleted, err := s.resolver.Mutation().DeleteLike(ctx, likeIDStr) + + // 3. Assertions + s.Require().NoError(err) + s.True(deleted) + + // Verify that the repository's Delete method was called + s.mockLikeRepo.AssertCalled(s.T(), "Delete", mock.Anything, uint(likeIDUint)) +} \ No newline at end of file diff --git a/internal/adapters/graphql/model/models_gen.go b/internal/adapters/graphql/model/models_gen.go index a1d8870..eb96721 100644 --- a/internal/adapters/graphql/model/models_gen.go +++ b/internal/adapters/graphql/model/models_gen.go @@ -20,14 +20,6 @@ type Address struct { Users []*User `json:"users,omitempty"` } -type Analytics struct { - TotalWorks int32 `json:"totalWorks"` - TotalTranslations int32 `json:"totalTranslations"` - TotalAuthors int32 `json:"totalAuthors"` - TotalUsers int32 `json:"totalUsers"` - TotalLikes int32 `json:"totalLikes"` -} - type AuthPayload struct { Token string `json:"token"` User *User `json:"user"` diff --git a/internal/adapters/graphql/resolver.go b/internal/adapters/graphql/resolver.go index 6ca60ba..5726e0b 100644 --- a/internal/adapters/graphql/resolver.go +++ b/internal/adapters/graphql/resolver.go @@ -1,15 +1,11 @@ package graphql -import ( - "tercul/internal/app" - "tercul/internal/application/services" -) +import "tercul/internal/app" // This file will not be regenerated automatically. // // It serves as dependency injection for your app, add any dependencies you require here. type Resolver struct { - App *app.Application - AnalyticsService services.AnalyticsService + App *app.Application } diff --git a/internal/adapters/graphql/schema.graphqls b/internal/adapters/graphql/schema.graphqls index afc0f5e..6ee2c6f 100644 --- a/internal/adapters/graphql/schema.graphqls +++ b/internal/adapters/graphql/schema.graphqls @@ -534,9 +534,6 @@ type Query { ): SearchResults! trendingWorks(timePeriod: String, limit: Int): [Work!]! - - # Analytics - analytics: Analytics! } input SearchFilters { @@ -555,14 +552,6 @@ type SearchResults { total: Int! } -type Analytics { - totalWorks: Int! - totalTranslations: Int! - totalAuthors: Int! - totalUsers: Int! - totalLikes: Int! -} - # Mutations type Mutation { # Authentication diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index 9ce5c8b..95f4630 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -578,6 +578,14 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, err } + // Increment analytics + if createdComment.WorkID != nil { + r.App.Analytics.IncrementWorkComments(ctx, *createdComment.WorkID) + } + if createdComment.TranslationID != nil { + r.App.Analytics.IncrementTranslationComments(ctx, *createdComment.TranslationID) + } + // Convert to GraphQL model return &model.Comment{ ID: fmt.Sprintf("%d", createdComment.ID), @@ -724,6 +732,14 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, err } + // Increment analytics + if createdLike.WorkID != nil { + r.App.Analytics.IncrementWorkLikes(ctx, *createdLike.WorkID) + } + if createdLike.TranslationID != nil { + r.App.Analytics.IncrementTranslationLikes(ctx, *createdLike.TranslationID) + } + // Convert to GraphQL model return &model.Like{ ID: fmt.Sprintf("%d", createdLike.ID), @@ -797,6 +813,9 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm return nil, err } + // Increment analytics + r.App.Analytics.IncrementWorkBookmarks(ctx, uint(workID)) + // Convert to GraphQL model return &model.Bookmark{ ID: fmt.Sprintf("%d", createdBookmark.ID), @@ -1210,23 +1229,31 @@ func (r *queryResolver) Search(ctx context.Context, query string, limit *int32, // TrendingWorks is the resolver for the trendingWorks field. func (r *queryResolver) TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) { - panic(fmt.Errorf("not implemented: TrendingWorks - trendingWorks")) -} + tp := "daily" + if timePeriod != nil { + tp = *timePeriod + } -// Analytics is the resolver for the analytics field. -func (r *queryResolver) Analytics(ctx context.Context) (*model.Analytics, error) { - analytics, err := r.AnalyticsService.GetAnalytics(ctx) + l := 10 + if limit != nil { + l = int(*limit) + } + + works, err := r.App.Analytics.GetTrendingWorks(ctx, tp, l) if err != nil { return nil, err } - return &model.Analytics{ - TotalWorks: int32(analytics.TotalWorks), - TotalTranslations: int32(analytics.TotalTranslations), - TotalAuthors: int32(analytics.TotalAuthors), - TotalUsers: int32(analytics.TotalUsers), - TotalLikes: int32(analytics.TotalLikes), - }, nil + var result []*model.Work + for _, w := range works { + result = append(result, &model.Work{ + ID: fmt.Sprintf("%d", w.ID), + Name: w.Title, + Language: w.Language, + }) + } + + return result, nil } // Mutation returns MutationResolver implementation. @@ -1237,3 +1264,63 @@ func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } + +// !!! WARNING !!! +// The code below was going to be deleted when updating resolvers. It has been copied here so you have +// one last chance to move it out of harms way if you want. There are two reasons this happens: +// - When renaming or deleting a resolver the old code will be put in here. You can safely delete +// it when you're done. +// - You have helper methods in this file. Move them out to keep these resolver files clean. +/* + func (r *translationResolver) Stats(ctx context.Context, obj *model.Translation) (*model.TranslationStats, error) { + translationID, err := strconv.ParseUint(obj.ID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid translation ID: %v", err) + } + + stats, err := r.App.Analytics.GetOrCreateTranslationStats(ctx, uint(translationID)) + if err != nil { + return nil, err + } + + // Convert domain model to GraphQL model + return &model.TranslationStats{ + ID: fmt.Sprintf("%d", stats.ID), + Views: toInt32(stats.Views), + Likes: toInt32(stats.Likes), + Comments: toInt32(stats.Comments), + Shares: toInt32(stats.Shares), + ReadingTime: toInt32(int64(stats.ReadingTime)), + Sentiment: &stats.Sentiment, + }, nil +} +func (r *workResolver) Stats(ctx context.Context, obj *model.Work) (*model.WorkStats, error) { + workID, err := strconv.ParseUint(obj.ID, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) + } + + stats, err := r.App.Analytics.GetOrCreateWorkStats(ctx, uint(workID)) + if err != nil { + return nil, err + } + + // Convert domain model to GraphQL model + return &model.WorkStats{ + ID: fmt.Sprintf("%d", stats.ID), + Views: toInt32(stats.Views), + Likes: toInt32(stats.Likes), + Comments: toInt32(stats.Comments), + Bookmarks: toInt32(stats.Bookmarks), + Shares: toInt32(stats.Shares), + TranslationCount: toInt32(stats.TranslationCount), + ReadingTime: toInt32(int64(stats.ReadingTime)), + Complexity: &stats.Complexity, + Sentiment: &stats.Sentiment, + }, nil +} +func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} } +func (r *Resolver) Work() WorkResolver { return &workResolver{r} } +type translationResolver struct{ *Resolver } +type workResolver struct{ *Resolver } +*/ diff --git a/internal/app/analytics/service.go b/internal/app/analytics/service.go new file mode 100644 index 0000000..87e1107 --- /dev/null +++ b/internal/app/analytics/service.go @@ -0,0 +1,301 @@ +package analytics + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "tercul/internal/domain" + "tercul/internal/jobs/linguistics" + "tercul/internal/platform/log" + "time" +) + +type Service interface { + IncrementWorkViews(ctx context.Context, workID uint) error + IncrementWorkLikes(ctx context.Context, workID uint) error + IncrementWorkComments(ctx context.Context, workID uint) error + IncrementWorkBookmarks(ctx context.Context, workID uint) error + IncrementWorkShares(ctx context.Context, workID uint) error + IncrementWorkTranslationCount(ctx context.Context, workID uint) error + IncrementTranslationViews(ctx context.Context, translationID uint) error + IncrementTranslationLikes(ctx context.Context, translationID uint) error + IncrementTranslationComments(ctx context.Context, translationID uint) error + IncrementTranslationShares(ctx context.Context, translationID uint) error + GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) + GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) + + UpdateWorkReadingTime(ctx context.Context, workID uint) error + UpdateWorkComplexity(ctx context.Context, workID uint) error + UpdateWorkSentiment(ctx context.Context, workID uint) error + UpdateTranslationReadingTime(ctx context.Context, translationID uint) error + UpdateTranslationSentiment(ctx context.Context, translationID uint) error + + UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error + UpdateTrending(ctx context.Context) error + GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) +} + +type service struct { + repo domain.AnalyticsRepository + analysisRepo linguistics.AnalysisRepository + translationRepo domain.TranslationRepository + workRepo domain.WorkRepository + sentimentProvider linguistics.SentimentProvider +} + +func NewService(repo domain.AnalyticsRepository, analysisRepo linguistics.AnalysisRepository, translationRepo domain.TranslationRepository, workRepo domain.WorkRepository, sentimentProvider linguistics.SentimentProvider) Service { + return &service{ + repo: repo, + analysisRepo: analysisRepo, + translationRepo: translationRepo, + workRepo: workRepo, + sentimentProvider: sentimentProvider, + } +} + +func (s *service) IncrementWorkViews(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "views", 1) +} + +func (s *service) IncrementWorkLikes(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "likes", 1) +} + +func (s *service) IncrementWorkComments(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "comments", 1) +} + +func (s *service) IncrementWorkBookmarks(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "bookmarks", 1) +} + +func (s *service) IncrementWorkShares(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "shares", 1) +} + +func (s *service) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { + return s.repo.IncrementWorkCounter(ctx, workID, "translation_count", 1) +} + +func (s *service) IncrementTranslationViews(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "views", 1) +} + +func (s *service) IncrementTranslationLikes(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "likes", 1) +} + +func (s *service) IncrementTranslationComments(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "comments", 1) +} + +func (s *service) IncrementTranslationShares(ctx context.Context, translationID uint) error { + return s.repo.IncrementTranslationCounter(ctx, translationID, "shares", 1) +} + +func (s *service) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + return s.repo.GetOrCreateWorkStats(ctx, workID) +} + +func (s *service) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + return s.repo.GetOrCreateTranslationStats(ctx, translationID) +} + +func (s *service) UpdateWorkReadingTime(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + textMetadata, _, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + return err + } + + if textMetadata == nil { + return errors.New("text metadata not found") + } + + readingTime := 0 + if textMetadata.WordCount > 0 { + readingTime = (textMetadata.WordCount + 199) / 200 // Ceil division + } + + stats.ReadingTime = readingTime + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateWorkComplexity(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + _, readabilityScore, _, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + log.LogWarn("could not get readability score for work", log.F("workID", workID), log.F("error", err)) + return nil + } + + if readabilityScore == nil { + return errors.New("readability score not found") + } + + stats.Complexity = readabilityScore.Score + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateWorkSentiment(ctx context.Context, workID uint) error { + stats, err := s.repo.GetOrCreateWorkStats(ctx, workID) + if err != nil { + return err + } + + _, _, languageAnalysis, err := s.analysisRepo.GetAnalysisData(ctx, workID) + if err != nil { + log.LogWarn("could not get language analysis for work", log.F("workID", workID), log.F("error", err)) + return nil + } + + if languageAnalysis == nil { + return errors.New("language analysis not found") + } + + sentiment, ok := languageAnalysis.Analysis["sentiment"].(float64) + if !ok { + return errors.New("sentiment score not found in language analysis") + } + + stats.Sentiment = sentiment + + return s.repo.UpdateWorkStats(ctx, workID, *stats) +} + +func (s *service) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error { + stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) + if err != nil { + return err + } + + translation, err := s.translationRepo.GetByID(ctx, translationID) + if err != nil { + return err + } + + if translation == nil { + return errors.New("translation not found") + } + + wordCount := len(strings.Fields(translation.Content)) + readingTime := 0 + if wordCount > 0 { + readingTime = (wordCount + 199) / 200 // Ceil division + } + + stats.ReadingTime = readingTime + + return s.repo.UpdateTranslationStats(ctx, translationID, *stats) +} + +func (s *service) UpdateTranslationSentiment(ctx context.Context, translationID uint) error { + stats, err := s.repo.GetOrCreateTranslationStats(ctx, translationID) + if err != nil { + return err + } + + translation, err := s.translationRepo.GetByID(ctx, translationID) + if err != nil { + return err + } + + if translation == nil { + return errors.New("translation not found") + } + + sentiment, err := s.sentimentProvider.Score(translation.Content, translation.Language) + if err != nil { + return err + } + + stats.Sentiment = sentiment + + return s.repo.UpdateTranslationStats(ctx, translationID, *stats) +} + +func (s *service) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error { + today := time.Now().UTC().Truncate(24 * time.Hour) + engagement, err := s.repo.GetOrCreateUserEngagement(ctx, userID, today) + if err != nil { + return err + } + + switch eventType { + case "work_read": + engagement.WorksRead++ + case "comment_made": + engagement.CommentsMade++ + case "like_given": + engagement.LikesGiven++ + case "bookmark_made": + engagement.BookmarksMade++ + case "translation_made": + engagement.TranslationsMade++ + default: + return errors.New("invalid engagement event type") + } + + return s.repo.UpdateUserEngagement(ctx, engagement) +} + +func (s *service) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { + return s.repo.GetTrendingWorks(ctx, timePeriod, limit) +} + +func (s *service) UpdateTrending(ctx context.Context) error { + log.LogInfo("Updating trending works") + + works, err := s.workRepo.ListAll(ctx) + if err != nil { + return fmt.Errorf("failed to list works: %w", err) + } + + trendingWorks := make([]*domain.Trending, 0, len(works)) + for _, work := range works { + stats, err := s.repo.GetOrCreateWorkStats(ctx, work.ID) + if err != nil { + log.LogWarn("failed to get work stats", log.F("workID", work.ID), log.F("error", err)) + continue + } + + score := float64(stats.Views*1 + stats.Likes*2 + stats.Comments*3) + + trendingWorks = append(trendingWorks, &domain.Trending{ + EntityType: "Work", + EntityID: work.ID, + Score: score, + TimePeriod: "daily", // Hardcoded for now + Date: time.Now().UTC(), + }) + } + + // Sort by score + sort.Slice(trendingWorks, func(i, j int) bool { + return trendingWorks[i].Score > trendingWorks[j].Score + }) + + // Get top 10 + if len(trendingWorks) > 10 { + trendingWorks = trendingWorks[:10] + } + + // Set ranks + for i := range trendingWorks { + trendingWorks[i].Rank = i + 1 + } + + return s.repo.UpdateTrendingWorks(ctx, "daily", trendingWorks) +} diff --git a/internal/app/analytics/service_test.go b/internal/app/analytics/service_test.go new file mode 100644 index 0000000..08f0963 --- /dev/null +++ b/internal/app/analytics/service_test.go @@ -0,0 +1,260 @@ +package analytics_test + +import ( + "context" + "strings" + "testing" + "tercul/internal/app/analytics" + "tercul/internal/data/sql" + "tercul/internal/domain" + "tercul/internal/jobs/linguistics" + "tercul/internal/testutil" + + "github.com/stretchr/testify/suite" +) + +type AnalyticsServiceTestSuite struct { + testutil.IntegrationTestSuite + service analytics.Service +} + +func (s *AnalyticsServiceTestSuite) SetupSuite() { + s.IntegrationTestSuite.SetupSuite(testutil.DefaultTestConfig()) + analyticsRepo := sql.NewAnalyticsRepository(s.DB) + analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB) + translationRepo := sql.NewTranslationRepository(s.DB) + workRepo := sql.NewWorkRepository(s.DB) + sentimentProvider, _ := linguistics.NewGoVADERSentimentProvider() + s.service = analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, sentimentProvider) +} + +func (s *AnalyticsServiceTestSuite) SetupTest() { + s.IntegrationTestSuite.SetupTest() + s.DB.Exec("DELETE FROM trendings") +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkViews() { + s.Run("should increment the view count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkViews(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Views) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkLikes() { + s.Run("should increment the like count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkLikes(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Likes) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkComments() { + s.Run("should increment the comment count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkComments(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Comments) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkBookmarks() { + s.Run("should increment the bookmark count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkBookmarks(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Bookmarks) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkShares() { + s.Run("should increment the share count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkShares(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.Shares) + }) +} + +func (s *AnalyticsServiceTestSuite) TestIncrementWorkTranslationCount() { + s.Run("should increment the translation count for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + + // Act + err := s.service.IncrementWorkTranslationCount(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(int64(1), stats.TranslationCount) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkReadingTime() { + s.Run("should update the reading time for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.ReadabilityScore{WorkID: work.ID}) + s.DB.Create(&domain.LanguageAnalysis{WorkID: work.ID, Analysis: domain.JSONB{}}) + textMetadata := &domain.TextMetadata{ + WorkID: work.ID, + WordCount: 1000, + } + s.DB.Create(textMetadata) + + // Act + err := s.service.UpdateWorkReadingTime(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(5, stats.ReadingTime) // 1000 words / 200 wpm = 5 minutes + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTranslationReadingTime() { + s.Run("should update the reading time for a translation", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation := s.CreateTestTranslation(work.ID, "es", strings.Repeat("Contenido de prueba con quinientas palabras. ", 100)) + + // Act + err := s.service.UpdateTranslationReadingTime(context.Background(), translation.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateTranslationStats(context.Background(), translation.ID) + s.Require().NoError(err) + s.Equal(3, stats.ReadingTime) // 500 words / 200 wpm = 2.5 -> 3 minutes + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkComplexity() { + s.Run("should update the complexity for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.TextMetadata{WorkID: work.ID}) + s.DB.Create(&domain.LanguageAnalysis{WorkID: work.ID, Analysis: domain.JSONB{}}) + readabilityScore := &domain.ReadabilityScore{ + WorkID: work.ID, + Score: 12.34, + } + s.DB.Create(readabilityScore) + + // Act + err := s.service.UpdateWorkComplexity(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(12.34, stats.Complexity) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateWorkSentiment() { + s.Run("should update the sentiment for a work", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + s.DB.Create(&domain.TextMetadata{WorkID: work.ID}) + s.DB.Create(&domain.ReadabilityScore{WorkID: work.ID}) + languageAnalysis := &domain.LanguageAnalysis{ + WorkID: work.ID, + Analysis: domain.JSONB{ + "sentiment": 0.5678, + }, + } + s.DB.Create(languageAnalysis) + + // Act + err := s.service.UpdateWorkSentiment(context.Background(), work.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateWorkStats(context.Background(), work.ID) + s.Require().NoError(err) + s.Equal(0.5678, stats.Sentiment) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTranslationSentiment() { + s.Run("should update the sentiment for a translation", func() { + // Arrange + work := s.CreateTestWork("Test Work", "en", "Test content") + translation := s.CreateTestTranslation(work.ID, "en", "This is a wonderfully positive and uplifting sentence.") + + // Act + err := s.service.UpdateTranslationSentiment(context.Background(), translation.ID) + s.Require().NoError(err) + + // Assert + stats, err := s.service.GetOrCreateTranslationStats(context.Background(), translation.ID) + s.Require().NoError(err) + s.True(stats.Sentiment > 0.5) + }) +} + +func (s *AnalyticsServiceTestSuite) TestUpdateTrending() { + s.Run("should update the trending works", func() { + // Arrange + work1 := s.CreateTestWork("Work 1", "en", "content") + work2 := s.CreateTestWork("Work 2", "en", "content") + s.DB.Create(&domain.WorkStats{WorkID: work1.ID, Views: 100, Likes: 10, Comments: 1}) + s.DB.Create(&domain.WorkStats{WorkID: work2.ID, Views: 10, Likes: 100, Comments: 10}) + + // Act + err := s.service.UpdateTrending(context.Background()) + s.Require().NoError(err) + + // Assert + var trendingWorks []*domain.Trending + s.DB.Order("rank asc").Find(&trendingWorks) + s.Require().Len(trendingWorks, 2) + s.Equal(work2.ID, trendingWorks[0].EntityID) + s.Equal(work1.ID, trendingWorks[1].EntityID) + }) +} + +func TestAnalyticsService(t *testing.T) { + suite.Run(t, new(AnalyticsServiceTestSuite)) +} diff --git a/internal/app/app.go b/internal/app/app.go index b13fa65..623102d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -1,6 +1,7 @@ package app import ( + "tercul/internal/app/analytics" "tercul/internal/app/author" "tercul/internal/app/bookmark" "tercul/internal/app/category" @@ -32,9 +33,10 @@ type Application struct { Localization *localization.Service Auth *auth.Service Work *work.Service + Analytics analytics.Service } -func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService any) *Application { +func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application { jwtManager := platform_auth.NewJWTManager() authorService := author.NewService(repos.Author) bookmarkService := bookmark.NewService(repos.Bookmark) @@ -62,5 +64,6 @@ func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, a Localization: localizationService, Auth: authService, Work: workService, + Analytics: analyticsService, } } \ No newline at end of file diff --git a/internal/app/bookmark/commands.go b/internal/app/bookmark/commands.go index 49557f7..5471f3c 100644 --- a/internal/app/bookmark/commands.go +++ b/internal/app/bookmark/commands.go @@ -12,9 +12,7 @@ type BookmarkCommands struct { // NewBookmarkCommands creates a new BookmarkCommands handler. func NewBookmarkCommands(repo domain.BookmarkRepository) *BookmarkCommands { - return &BookmarkCommands{ - repo: repo, - } + return &BookmarkCommands{repo: repo} } // CreateBookmarkInput represents the input for creating a new bookmark. @@ -37,7 +35,6 @@ func (c *BookmarkCommands) CreateBookmark(ctx context.Context, input CreateBookm if err != nil { return nil, err } - return bookmark, nil } diff --git a/internal/app/like/commands.go b/internal/app/like/commands.go index 69d5d54..79d2097 100644 --- a/internal/app/like/commands.go +++ b/internal/app/like/commands.go @@ -12,9 +12,7 @@ type LikeCommands struct { // NewLikeCommands creates a new LikeCommands handler. func NewLikeCommands(repo domain.LikeRepository) *LikeCommands { - return &LikeCommands{ - repo: repo, - } + return &LikeCommands{repo: repo} } // CreateLikeInput represents the input for creating a new like. @@ -37,7 +35,6 @@ func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (* if err != nil { return nil, err } - return like, nil } diff --git a/internal/application/services/analytics_service.go b/internal/application/services/analytics_service.go deleted file mode 100644 index 54aec31..0000000 --- a/internal/application/services/analytics_service.go +++ /dev/null @@ -1,77 +0,0 @@ -package services - -import ( - "context" - "tercul/internal/domain" -) - -type AnalyticsService interface { - GetAnalytics(ctx context.Context) (*Analytics, error) -} - -type analyticsService struct { - workRepo domain.WorkRepository - translationRepo domain.TranslationRepository - authorRepo domain.AuthorRepository - userRepo domain.UserRepository - likeRepo domain.LikeRepository -} - -func NewAnalyticsService( - workRepo domain.WorkRepository, - translationRepo domain.TranslationRepository, - authorRepo domain.AuthorRepository, - userRepo domain.UserRepository, - likeRepo domain.LikeRepository, -) AnalyticsService { - return &analyticsService{ - workRepo: workRepo, - translationRepo: translationRepo, - authorRepo: authorRepo, - userRepo: userRepo, - likeRepo: likeRepo, - } -} - -type Analytics struct { - TotalWorks int64 - TotalTranslations int64 - TotalAuthors int64 - TotalUsers int64 - TotalLikes int64 -} - -func (s *analyticsService) GetAnalytics(ctx context.Context) (*Analytics, error) { - totalWorks, err := s.workRepo.Count(ctx) - if err != nil { - return nil, err - } - - totalTranslations, err := s.translationRepo.Count(ctx) - if err != nil { - return nil, err - } - - totalAuthors, err := s.authorRepo.Count(ctx) - if err != nil { - return nil, err - } - - totalUsers, err := s.userRepo.Count(ctx) - if err != nil { - return nil, err - } - - totalLikes, err := s.likeRepo.Count(ctx) - if err != nil { - return nil, err - } - - return &Analytics{ - TotalWorks: totalWorks, - TotalTranslations: totalTranslations, - TotalAuthors: totalAuthors, - TotalUsers: totalUsers, - TotalLikes: totalLikes, - }, nil -} \ No newline at end of file diff --git a/internal/application/services/analytics_service_test.go b/internal/application/services/analytics_service_test.go deleted file mode 100644 index 5ed05c6..0000000 --- a/internal/application/services/analytics_service_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package services - -import ( - "context" - "testing" - "tercul/internal/domain" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -// Mock Repositories -type MockWorkRepository struct { - mock.Mock - domain.WorkRepository -} - -func (m *MockWorkRepository) Count(ctx context.Context) (int64, error) { - args := m.Called(ctx) - return args.Get(0).(int64), args.Error(1) -} - -// Implement other methods of the WorkRepository interface if needed for other tests - -type MockTranslationRepository struct { - mock.Mock - domain.TranslationRepository -} - -func (m *MockTranslationRepository) Count(ctx context.Context) (int64, error) { - args := m.Called(ctx) - return args.Get(0).(int64), args.Error(1) -} - -// Implement other methods of the TranslationRepository interface if needed for other tests - -type MockAuthorRepository struct { - mock.Mock - domain.AuthorRepository -} - -func (m *MockAuthorRepository) Count(ctx context.Context) (int64, error) { - args := m.Called(ctx) - return args.Get(0).(int64), args.Error(1) -} - -// Implement other methods of the AuthorRepository interface if needed for other tests - -type MockUserRepository struct { - mock.Mock - domain.UserRepository -} - -func (m *MockUserRepository) Count(ctx context.Context) (int64, error) { - args := m.Called(ctx) - return args.Get(0).(int64), args.Error(1) -} - -// Implement other methods of the UserRepository interface if needed for other tests - -type MockLikeRepository struct { - mock.Mock - domain.LikeRepository -} - -func (m *MockLikeRepository) Count(ctx context.Context) (int64, error) { - args := m.Called(ctx) - return args.Get(0).(int64), args.Error(1) -} - -// Implement other methods of the LikeRepository interface if needed for other tests - -func TestAnalyticsService_GetAnalytics(t *testing.T) { - ctx := context.Background() - - mockWorkRepo := new(MockWorkRepository) - mockTranslationRepo := new(MockTranslationRepository) - mockAuthorRepo := new(MockAuthorRepository) - mockUserRepo := new(MockUserRepository) - mockLikeRepo := new(MockLikeRepository) - - mockWorkRepo.On("Count", ctx).Return(int64(10), nil) - mockTranslationRepo.On("Count", ctx).Return(int64(20), nil) - mockAuthorRepo.On("Count", ctx).Return(int64(5), nil) - mockUserRepo.On("Count", ctx).Return(int64(100), nil) - mockLikeRepo.On("Count", ctx).Return(int64(50), nil) - - service := NewAnalyticsService(mockWorkRepo, mockTranslationRepo, mockAuthorRepo, mockUserRepo, mockLikeRepo) - - analytics, err := service.GetAnalytics(ctx) - - assert.NoError(t, err) - assert.NotNil(t, analytics) - assert.Equal(t, int64(10), analytics.TotalWorks) - assert.Equal(t, int64(20), analytics.TotalTranslations) - assert.Equal(t, int64(5), analytics.TotalAuthors) - assert.Equal(t, int64(100), analytics.TotalUsers) - assert.Equal(t, int64(50), analytics.TotalLikes) - - mockWorkRepo.AssertExpectations(t) - mockTranslationRepo.AssertExpectations(t) - mockAuthorRepo.AssertExpectations(t) - mockUserRepo.AssertExpectations(t) - mockLikeRepo.AssertExpectations(t) -} \ No newline at end of file diff --git a/internal/jobs/trending/trending.go b/internal/jobs/trending/trending.go index cd22e28..9525230 100644 --- a/internal/jobs/trending/trending.go +++ b/internal/jobs/trending/trending.go @@ -3,8 +3,7 @@ package trending import ( "context" "encoding/json" - "fmt" - "tercul/internal/application/services" + "tercul/internal/app/analytics" "github.com/hibiken/asynq" ) @@ -25,17 +24,16 @@ func NewUpdateTrendingTask() (*asynq.Task, error) { return asynq.NewTask(TaskUpdateTrending, payload), nil } -func HandleUpdateTrendingTask(analyticsService services.AnalyticsService) asynq.HandlerFunc { +func HandleUpdateTrendingTask(analyticsService analytics.Service) asynq.HandlerFunc { return func(ctx context.Context, t *asynq.Task) error { var p UpdateTrendingPayload if err := json.Unmarshal(t.Payload(), &p); err != nil { return err } - // return analyticsService.UpdateTrending(ctx) - panic(fmt.Errorf("not implemented: Analytics - analytics")) + return analyticsService.UpdateTrending(ctx) } } -func RegisterTrendingHandlers(mux *asynq.ServeMux, analyticsService services.AnalyticsService) { +func RegisterTrendingHandlers(mux *asynq.ServeMux, analyticsService analytics.Service) { mux.HandleFunc(TaskUpdateTrending, HandleUpdateTrendingTask(analyticsService)) } diff --git a/internal/platform/auth/middleware.go b/internal/platform/auth/middleware.go index 25a0835..cb379ad 100644 --- a/internal/platform/auth/middleware.go +++ b/internal/platform/auth/middleware.go @@ -181,3 +181,9 @@ func shouldSkipAuth(path string) bool { return false } + +// ContextWithUserID adds a user ID to the context for testing purposes. +func ContextWithUserID(ctx context.Context, userID uint) context.Context { + claims := &Claims{UserID: userID} + return context.WithValue(ctx, ClaimsContextKey, claims) +} diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index 0dd983b..4be68b2 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -6,10 +6,12 @@ import ( "os" "path/filepath" "tercul/internal/app" + "tercul/internal/app/analytics" "tercul/internal/app/translation" "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/domain/search" + "tercul/internal/jobs/linguistics" "time" "github.com/stretchr/testify/suite" @@ -25,13 +27,61 @@ func (m *mockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pip return nil } +// mockAnalyticsService is a mock implementation of the AnalyticsService interface. +type mockAnalyticsService struct{} + +func (m *mockAnalyticsService) IncrementWorkViews(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkLikes(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkComments(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementWorkBookmarks(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementWorkShares(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) IncrementWorkTranslationCount(ctx context.Context, workID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationViews(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationLikes(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationComments(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) IncrementTranslationShares(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + return &domain.WorkStats{}, nil +} +func (m *mockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + return &domain.TranslationStats{}, nil +} +func (m *mockAnalyticsService) UpdateWorkReadingTime(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) UpdateWorkComplexity(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) UpdateWorkSentiment(ctx context.Context, workID uint) error { return nil } +func (m *mockAnalyticsService) UpdateTranslationReadingTime(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) UpdateTranslationSentiment(ctx context.Context, translationID uint) error { + return nil +} +func (m *mockAnalyticsService) UpdateUserEngagement(ctx context.Context, userID uint, eventType string) error { + return nil +} +func (m *mockAnalyticsService) UpdateTrending(ctx context.Context) error { return nil } +func (m *mockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { + return nil, nil +} // IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories type IntegrationTestSuite struct { suite.Suite - App *app.Application - DB *gorm.DB - Repos *sql.Repositories + App *app.Application + DB *gorm.DB } // TestConfig holds configuration for the test environment @@ -98,9 +148,15 @@ func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) { &domain.TranslationStats{}, &TestEntity{}, ) - s.Repos = sql.NewRepositories(s.DB) + repos := sql.NewRepositories(s.DB) var searchClient search.SearchClient = &mockSearchClient{} - s.App = app.NewApplication(s.Repos, searchClient, nil) + analysisRepo := linguistics.NewGORMAnalysisRepository(s.DB) + sentimentProvider, err := linguistics.NewGoVADERSentimentProvider() + if err != nil { + s.T().Fatalf("Failed to create sentiment provider: %v", err) + } + analyticsService := analytics.NewService(repos.Analytics, analysisRepo, repos.Translation, repos.Work, sentimentProvider) + s.App = app.NewApplication(repos, searchClient, analyticsService) } // TearDownSuite cleans up the test suite diff --git a/internal/testutil/mock_analytics_service.go b/internal/testutil/mock_analytics_service.go new file mode 100644 index 0000000..356c940 --- /dev/null +++ b/internal/testutil/mock_analytics_service.go @@ -0,0 +1,101 @@ +package testutil + +import ( + "context" + "tercul/internal/domain" + "time" + + "github.com/stretchr/testify/mock" +) + +// MockAnalyticsService is a mock implementation of the analytics.Service interface. +type MockAnalyticsService struct { + mock.Mock +} + +func (m *MockAnalyticsService) GetTrendingWorks(ctx context.Context, timePeriod string, limit int) ([]*domain.Work, error) { + args := m.Called(ctx, timePeriod, limit) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]*domain.Work), args.Error(1) +} + +func (m *MockAnalyticsService) UpdateTrending(ctx context.Context) error { + args := m.Called(ctx) + return args.Error(0) +} + +func (m *MockAnalyticsService) IncrementWorkLikes(ctx context.Context, workID uint) { + m.Called(ctx, workID) +} + +func (m *MockAnalyticsService) IncrementTranslationLikes(ctx context.Context, translationID uint) { + m.Called(ctx, translationID) +} + +func (m *MockAnalyticsService) IncrementWorkComments(ctx context.Context, workID uint) { + m.Called(ctx, workID) +} + +func (m *MockAnalyticsService) IncrementTranslationComments(ctx context.Context, translationID uint) { + m.Called(ctx, translationID) +} + +func (m *MockAnalyticsService) IncrementWorkBookmarks(ctx context.Context, workID uint) { + m.Called(ctx, workID) +} + +func (m *MockAnalyticsService) GetOrCreateWorkStats(ctx context.Context, workID uint) (*domain.WorkStats, error) { + args := m.Called(ctx, workID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.WorkStats), args.Error(1) +} + +func (m *MockAnalyticsService) GetOrCreateTranslationStats(ctx context.Context, translationID uint) (*domain.TranslationStats, error) { + args := m.Called(ctx, translationID) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.TranslationStats), args.Error(1) +} + +func (m *MockAnalyticsService) GetOrCreateUserEngagement(ctx context.Context, userID uint, date time.Time) (*domain.UserEngagement, error) { + args := m.Called(ctx, userID, date) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.UserEngagement), args.Error(1) +} + +func (m *MockAnalyticsService) UpdateUserEngagement(ctx context.Context, engagement *domain.UserEngagement) error { + args := m.Called(ctx, engagement) + return args.Error(0) +} + +func (m *MockAnalyticsService) IncrementWorkCounter(ctx context.Context, workID uint, counter string, value int) error { + args := m.Called(ctx, workID, counter, value) + return args.Error(0) +} + +func (m *MockAnalyticsService) IncrementTranslationCounter(ctx context.Context, translationID uint, counter string, value int) error { + args := m.Called(ctx, translationID, counter, value) + return args.Error(0) +} + +func (m *MockAnalyticsService) UpdateWorkStats(ctx context.Context, workID uint, stats domain.WorkStats) error { + args := m.Called(ctx, workID, stats) + return args.Error(0) +} + +func (m *MockAnalyticsService) UpdateTranslationStats(ctx context.Context, translationID uint, stats domain.TranslationStats) error { + args := m.Called(ctx, translationID, stats) + return args.Error(0) +} + +func (m *MockAnalyticsService) UpdateTrendingWorks(ctx context.Context, timePeriod string, trendingWorks []*domain.Trending) error { + args := m.Called(ctx, timePeriod, trendingWorks) + return args.Error(0) +} \ No newline at end of file diff --git a/internal/testutil/mock_jwt_manager.go b/internal/testutil/mock_jwt_manager.go new file mode 100644 index 0000000..d5ca5c9 --- /dev/null +++ b/internal/testutil/mock_jwt_manager.go @@ -0,0 +1,40 @@ +package testutil + +import ( + "tercul/internal/domain" + "tercul/internal/platform/auth" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +// MockJWTManager is a mock implementation of the JWTManagement interface. +type MockJWTManager struct{} + +// NewMockJWTManager creates a new MockJWTManager. +func NewMockJWTManager() auth.JWTManagement { + return &MockJWTManager{} +} + +// GenerateToken generates a dummy token for a user. +func (m *MockJWTManager) GenerateToken(user *domain.User) (string, error) { + return "dummy-token-for-" + user.Username, nil +} + +// ValidateToken validates a dummy token. +func (m *MockJWTManager) ValidateToken(tokenString string) (*auth.Claims, error) { + if tokenString != "" { + // A real implementation would parse the user from the token. + // For this mock, we'll just return a generic user. + return &auth.Claims{ + UserID: 1, + Username: "testuser", + Email: "test@test.com", + Role: "reader", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + }, + }, nil + } + return nil, auth.ErrInvalidToken +} \ No newline at end of file diff --git a/internal/testutil/mock_like_repository.go b/internal/testutil/mock_like_repository.go new file mode 100644 index 0000000..b54fd3a --- /dev/null +++ b/internal/testutil/mock_like_repository.go @@ -0,0 +1,152 @@ +package testutil + +import ( + "context" + "tercul/internal/domain" + + "github.com/stretchr/testify/mock" + "gorm.io/gorm" +) + +// MockLikeRepository is a mock implementation of the LikeRepository interface. +type MockLikeRepository struct { + mock.Mock + Likes []*domain.Like // Keep for other potential tests, but new mocks will use testify +} + +// NewMockLikeRepository creates a new MockLikeRepository. +func NewMockLikeRepository() *MockLikeRepository { + return &MockLikeRepository{Likes: []*domain.Like{}} +} + +// Create uses the mock's Called method. +func (m *MockLikeRepository) Create(ctx context.Context, like *domain.Like) error { + args := m.Called(ctx, like) + return args.Error(0) +} + +// GetByID retrieves a like by its ID from the mock repository. +func (m *MockLikeRepository) GetByID(ctx context.Context, id uint) (*domain.Like, error) { + args := m.Called(ctx, id) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Like), args.Error(1) +} + +// ListByUserID retrieves likes by their user ID from the mock repository. +func (m *MockLikeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { + var likes []domain.Like + for _, l := range m.Likes { + if l.UserID == userID { + likes = append(likes, *l) + } + } + return likes, nil +} + +// ListByWorkID retrieves likes by their work ID from the mock repository. +func (m *MockLikeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { + var likes []domain.Like + for _, l := range m.Likes { + if l.WorkID != nil && *l.WorkID == workID { + likes = append(likes, *l) + } + } + return likes, nil +} + +// ListByTranslationID retrieves likes by their translation ID from the mock repository. +func (m *MockLikeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { + var likes []domain.Like + for _, l := range m.Likes { + if l.TranslationID != nil && *l.TranslationID == translationID { + likes = append(likes, *l) + } + } + return likes, nil +} + +// ListByCommentID retrieves likes by their comment ID from the mock repository. +func (m *MockLikeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { + var likes []domain.Like + for _, l := range m.Likes { + if l.CommentID != nil && *l.CommentID == commentID { + likes = append(likes, *l) + } + } + return likes, nil +} + +// The rest of the BaseRepository methods can be stubbed out or implemented as needed. + +func (m *MockLikeRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { + return m.Create(ctx, entity) +} + +func (m *MockLikeRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Like, error) { + return m.GetByID(ctx, id) +} + +func (m *MockLikeRepository) Update(ctx context.Context, entity *domain.Like) error { + args := m.Called(ctx, entity) + return args.Error(0) +} + +func (m *MockLikeRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { + return m.Update(ctx, entity) +} + +func (m *MockLikeRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockLikeRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + return m.Delete(ctx, id) +} + +func (m *MockLikeRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Like], error) { + panic("not implemented") +} + +func (m *MockLikeRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Like, error) { + panic("not implemented") +} + +func (m *MockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { + var likes []domain.Like + for _, l := range m.Likes { + likes = append(likes, *l) + } + return likes, nil +} + +func (m *MockLikeRepository) Count(ctx context.Context) (int64, error) { + return int64(len(m.Likes)), nil +} + +func (m *MockLikeRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + panic("not implemented") +} + +func (m *MockLikeRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Like, error) { + return m.GetByID(ctx, id) +} + +func (m *MockLikeRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Like, error) { + panic("not implemented") +} + +func (m *MockLikeRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockLikeRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockLikeRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} \ No newline at end of file diff --git a/internal/testutil/mock_like_service.go b/internal/testutil/mock_like_service.go new file mode 100644 index 0000000..00fa7c4 --- /dev/null +++ b/internal/testutil/mock_like_service.go @@ -0,0 +1,27 @@ +package testutil + +import ( + "context" + "tercul/internal/app/like" + "tercul/internal/domain" + + "github.com/stretchr/testify/mock" +) + +// MockLikeService is a mock implementation of the like.Commands interface. +type MockLikeService struct { + mock.Mock +} + +func (m *MockLikeService) CreateLike(ctx context.Context, input like.CreateLikeInput) (*domain.Like, error) { + args := m.Called(ctx, input) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*domain.Like), args.Error(1) +} + +func (m *MockLikeService) DeleteLike(ctx context.Context, likeID uint) error { + args := m.Called(ctx, likeID) + return args.Error(0) +} \ No newline at end of file diff --git a/internal/testutil/mock_user_repository.go b/internal/testutil/mock_user_repository.go new file mode 100644 index 0000000..0684bba --- /dev/null +++ b/internal/testutil/mock_user_repository.go @@ -0,0 +1,134 @@ +package testutil + +import ( + "context" + "strings" + "tercul/internal/domain" + + "gorm.io/gorm" +) + +// MockUserRepository is a mock implementation of the UserRepository interface. +type MockUserRepository struct { + Users []*domain.User +} + +// NewMockUserRepository creates a new MockUserRepository. +func NewMockUserRepository() *MockUserRepository { + return &MockUserRepository{Users: []*domain.User{}} +} + +// Create adds a new user to the mock repository. +func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { + user.ID = uint(len(m.Users) + 1) + m.Users = append(m.Users, user) + return nil +} + +// GetByID retrieves a user by their ID from the mock repository. +func (m *MockUserRepository) GetByID(ctx context.Context, id uint) (*domain.User, error) { + for _, u := range m.Users { + if u.ID == id { + return u, nil + } + } + return nil, gorm.ErrRecordNotFound +} + +// FindByUsername retrieves a user by their username from the mock repository. +func (m *MockUserRepository) FindByUsername(ctx context.Context, username string) (*domain.User, error) { + for _, u := range m.Users { + if strings.EqualFold(u.Username, username) { + return u, nil + } + } + return nil, gorm.ErrRecordNotFound +} + +// FindByEmail retrieves a user by their email from the mock repository. +func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + for _, u := range m.Users { + if strings.EqualFold(u.Email, email) { + return u, nil + } + } + return nil, gorm.ErrRecordNotFound +} + +// ListByRole retrieves users by their role from the mock repository. +func (m *MockUserRepository) ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) { + var users []domain.User + for _, u := range m.Users { + if u.Role == role { + users = append(users, *u) + } + } + return users, nil +} + +// The rest of the BaseRepository methods can be stubbed out or implemented as needed. +func (m *MockUserRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error { + return m.Create(ctx, entity) +} +func (m *MockUserRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.User, error) { + return m.GetByID(ctx, id) +} +func (m *MockUserRepository) Update(ctx context.Context, entity *domain.User) error { + for i, u := range m.Users { + if u.ID == entity.ID { + m.Users[i] = entity + return nil + } + } + return gorm.ErrRecordNotFound +} +func (m *MockUserRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error { + return m.Update(ctx, entity) +} +func (m *MockUserRepository) Delete(ctx context.Context, id uint) error { + for i, u := range m.Users { + if u.ID == id { + m.Users = append(m.Users[:i], m.Users[i+1:]...) + return nil + } + } + return gorm.ErrRecordNotFound +} +func (m *MockUserRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + return m.Delete(ctx, id) +} +func (m *MockUserRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.User], error) { + panic("not implemented") +} +func (m *MockUserRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.User, error) { + panic("not implemented") +} +func (m *MockUserRepository) ListAll(ctx context.Context) ([]domain.User, error) { + var users []domain.User + for _, u := range m.Users { + users = append(users, *u) + } + return users, nil +} +func (m *MockUserRepository) Count(ctx context.Context) (int64, error) { + return int64(len(m.Users)), nil +} +func (m *MockUserRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + panic("not implemented") +} +func (m *MockUserRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.User, error) { + return m.GetByID(ctx, id) +} +func (m *MockUserRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.User, error) { + panic("not implemented") +} +func (m *MockUserRepository) Exists(ctx context.Context, id uint) (bool, error) { + _, err := m.GetByID(ctx, id) + return err == nil, nil +} +func (m *MockUserRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} +func (m *MockUserRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} \ No newline at end of file diff --git a/internal/testutil/mock_work_repository.go b/internal/testutil/mock_work_repository.go index 4b611bc..33cbb1d 100644 --- a/internal/testutil/mock_work_repository.go +++ b/internal/testutil/mock_work_repository.go @@ -2,254 +2,123 @@ package testutil import ( "context" - "gorm.io/gorm" "tercul/internal/domain" + + "github.com/stretchr/testify/mock" + "gorm.io/gorm" ) -// UnifiedMockWorkRepository is a shared mock for WorkRepository tests -// Implements all required methods and uses an in-memory slice - -type UnifiedMockWorkRepository struct { +// MockWorkRepository is a mock implementation of the WorkRepository interface. +type MockWorkRepository struct { + mock.Mock Works []*domain.Work } -func NewUnifiedMockWorkRepository() *UnifiedMockWorkRepository { - return &UnifiedMockWorkRepository{Works: []*domain.Work{}} +// NewMockWorkRepository creates a new MockWorkRepository. +func NewMockWorkRepository() *MockWorkRepository { + return &MockWorkRepository{Works: []*domain.Work{}} } -func (m *UnifiedMockWorkRepository) AddWork(work *domain.Work) { +// Create adds a new work to the mock repository. +func (m *MockWorkRepository) Create(ctx context.Context, work *domain.Work) error { work.ID = uint(len(m.Works) + 1) m.Works = append(m.Works, work) -} - -// BaseRepository methods with context support -func (m *UnifiedMockWorkRepository) Create(ctx context.Context, entity *domain.Work) error { - m.AddWork(entity) return nil } -func (m *UnifiedMockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) { +// GetByID retrieves a work by its ID from the mock repository. +func (m *MockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) { for _, w := range m.Works { if w.ID == id { return w, nil } } - return nil, ErrEntityNotFound + return nil, gorm.ErrRecordNotFound } -func (m *UnifiedMockWorkRepository) Update(ctx context.Context, entity *domain.Work) error { +// Exists uses the mock's Called method. +func (m *MockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +// The rest of the WorkRepository and BaseRepository methods can be stubbed out. +func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { + panic("not implemented") +} +func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { + panic("not implemented") +} +func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { + panic("not implemented") +} +func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + panic("not implemented") +} +func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { + return m.GetByID(ctx, id) +} +func (m *MockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + panic("not implemented") +} +func (m *MockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { + return m.Create(ctx, entity) +} +func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + return m.GetByID(ctx, id) +} +func (m *MockWorkRepository) Update(ctx context.Context, entity *domain.Work) error { for i, w := range m.Works { if w.ID == entity.ID { m.Works[i] = entity return nil } } - return ErrEntityNotFound + return gorm.ErrRecordNotFound } - -func (m *UnifiedMockWorkRepository) Delete(ctx context.Context, id uint) error { +func (m *MockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { + return m.Update(ctx, entity) +} +func (m *MockWorkRepository) Delete(ctx context.Context, id uint) error { for i, w := range m.Works { if w.ID == id { m.Works = append(m.Works[:i], m.Works[i+1:]...) return nil } } - return ErrEntityNotFound + return gorm.ErrRecordNotFound } - -func (m *UnifiedMockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { - var all []domain.Work - for _, w := range m.Works { - if w != nil { - all = append(all, *w) - } - } - total := int64(len(all)) - start := (page - 1) * pageSize - end := start + pageSize - if start > len(all) { - return &domain.PaginatedResult[domain.Work]{Items: []domain.Work{}, TotalCount: total}, nil - } - if end > len(all) { - end = len(all) - } - return &domain.PaginatedResult[domain.Work]{Items: all[start:end], TotalCount: total}, nil -} - -func (m *UnifiedMockWorkRepository) ListAll(ctx context.Context) ([]domain.Work, error) { - var all []domain.Work - for _, w := range m.Works { - if w != nil { - all = append(all, *w) - } - } - return all, nil -} - -func (m *UnifiedMockWorkRepository) Count(ctx context.Context) (int64, error) { - return int64(len(m.Works)), nil -} - -func (m *UnifiedMockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Work, error) { - for _, w := range m.Works { - if w.ID == id { - return w, nil - } - } - return nil, ErrEntityNotFound -} - -func (m *UnifiedMockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Work, error) { - var result []domain.Work - end := offset + batchSize - if end > len(m.Works) { - end = len(m.Works) - } - for i := offset; i < end; i++ { - if m.Works[i] != nil { - result = append(result, *m.Works[i]) - } - } - return result, nil -} - -// New BaseRepository methods -func (m *UnifiedMockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { - return m.Create(ctx, entity) -} - -func (m *UnifiedMockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { - return m.GetByID(ctx, id) -} - -func (m *UnifiedMockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { - return m.Update(ctx, entity) -} - -func (m *UnifiedMockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { +func (m *MockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return m.Delete(ctx, id) } - -func (m *UnifiedMockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Work, error) { - result, err := m.List(ctx, 1, 1000) - if err != nil { - return nil, err +func (m *MockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + panic("not implemented") +} +func (m *MockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Work, error) { + panic("not implemented") +} +func (m *MockWorkRepository) ListAll(ctx context.Context) ([]domain.Work, error) { + var works []domain.Work + for _, w := range m.Works { + works = append(works, *w) } - return result.Items, nil + return works, nil } - -func (m *UnifiedMockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - return m.Count(ctx) +func (m *MockWorkRepository) Count(ctx context.Context) (int64, error) { + return int64(len(m.Works)), nil } - -func (m *UnifiedMockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { - _, err := m.GetByID(ctx, id) - return err == nil, nil +func (m *MockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + panic("not implemented") } - -func (m *UnifiedMockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { +func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Work, error) { + return m.GetByID(ctx, id) +} +func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Work, error) { + panic("not implemented") +} +func (m *MockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } - -func (m *UnifiedMockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { +func (m *MockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { return fn(nil) -} - -// WorkRepository specific methods -func (m *UnifiedMockWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { - var result []domain.Work - for _, w := range m.Works { - if len(title) == 0 || (len(w.Title) >= len(title) && w.Title[:len(title)] == title) { - result = append(result, *w) - } - } - return result, nil -} - -func (m *UnifiedMockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { - var filtered []domain.Work - for _, w := range m.Works { - if w.Language == language { - filtered = append(filtered, *w) - } - } - total := int64(len(filtered)) - start := (page - 1) * pageSize - end := start + pageSize - if start > len(filtered) { - return &domain.PaginatedResult[domain.Work]{Items: []domain.Work{}, TotalCount: total}, nil - } - if end > len(filtered) { - end = len(filtered) - } - return &domain.PaginatedResult[domain.Work]{Items: filtered[start:end], TotalCount: total}, nil -} - -func (m *UnifiedMockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { - result := make([]domain.Work, len(m.Works)) - for i, w := range m.Works { - if w != nil { - result[i] = *w - } - } - return result, nil -} - -func (m *UnifiedMockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { - result := make([]domain.Work, len(m.Works)) - for i, w := range m.Works { - if w != nil { - result[i] = *w - } - } - return result, nil -} - -func (m *UnifiedMockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { - for _, w := range m.Works { - if w.ID == id { - return w, nil - } - } - return nil, ErrEntityNotFound -} - -func (m *UnifiedMockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { - var all []domain.Work - for _, w := range m.Works { - if w != nil { - all = append(all, *w) - } - } - total := int64(len(all)) - start := (page - 1) * pageSize - end := start + pageSize - if start > len(all) { - return &domain.PaginatedResult[domain.Work]{Items: []domain.Work{}, TotalCount: total}, nil - } - if end > len(all) { - end = len(all) - } - return &domain.PaginatedResult[domain.Work]{Items: all[start:end], TotalCount: total}, nil -} - -func (m *UnifiedMockWorkRepository) Reset() { - m.Works = []*domain.Work{} -} - -// Add helper to get GraphQL-style Work with Name mapped from Title -func (m *UnifiedMockWorkRepository) GetGraphQLWorkByID(id uint) map[string]interface{} { - for _, w := range m.Works { - if w.ID == id { - return map[string]interface{}{ - "id": w.ID, - "name": w.Title, - "language": w.Language, - "content": "", - } - } - } - return nil -} - -// Add other interface methods as needed for your tests +} \ No newline at end of file diff --git a/internal/testutil/simple_test_utils.go b/internal/testutil/simple_test_utils.go index 84e469b..627e8cc 100644 --- a/internal/testutil/simple_test_utils.go +++ b/internal/testutil/simple_test_utils.go @@ -15,7 +15,7 @@ import ( // SimpleTestSuite provides a minimal test environment with just the essentials type SimpleTestSuite struct { suite.Suite - WorkRepo *UnifiedMockWorkRepository + WorkRepo *MockWorkRepository WorkService *work.Service MockSearchClient *MockSearchClient } @@ -30,14 +30,14 @@ func (m *MockSearchClient) IndexWork(ctx context.Context, work *domain.Work, pip // SetupSuite sets up the test suite func (s *SimpleTestSuite) SetupSuite() { - s.WorkRepo = NewUnifiedMockWorkRepository() + s.WorkRepo = NewMockWorkRepository() s.MockSearchClient = &MockSearchClient{} s.WorkService = work.NewService(s.WorkRepo, s.MockSearchClient) } // SetupTest resets test data for each test func (s *SimpleTestSuite) SetupTest() { - s.WorkRepo.Reset() + s.WorkRepo = NewMockWorkRepository() } // MockLocalizationRepository is a mock implementation of the localization repository.