diff --git a/TODO.md b/TODO.md index 8170471..d0ff940 100644 --- a/TODO.md +++ b/TODO.md @@ -2,61 +2,35 @@ --- -## Suggested Next Objectives - -- [x] **Complete the Architecture Refactor (High, 5d):** Finalize the transition to a clean, domain-driven architecture. This will significantly improve maintainability, scalability, and developer velocity. - - [x] Ensure resolvers call application services only and add dataloaders per aggregate. - - [ ] Adopt a migrations tool and move all SQL to migration files. - - [ ] Implement full observability with centralized logging, metrics, and tracing. -- [x] **Full Test Coverage (High, 5d):** Increase test coverage across the application to ensure stability and prevent regressions. - - [x] Write unit tests for all models, repositories, and services. - - [x] Refactor existing tests to use mocks instead of a real database. -- [ ] **Implement Analytics Features (High, 3d):** Add analytics to provide insights into user engagement and content popularity. - - [ ] Implement view, like, comment, and bookmark counting. - - [ ] Track translation analytics to identify popular translations. -- [ ] **Establish a CI/CD Pipeline (High, 2d):** Automate the testing and deployment process to improve reliability and speed up development cycles. - - [ ] Add `make lint test test-integration` to the CI pipeline. - - [ ] Set up automated deployments to a staging environment. -- [ ] **Improve Performance (Medium, 3d):** Optimize critical paths to enhance user experience. - - [ ] Implement batching for Weaviate operations. - - [ ] Add performance benchmarks for critical paths. - ---- - -## [ ] High Priority +## High Priority ### [ ] Architecture Refactor (DDD-lite) -- [x] Refactor domains to be more testable and decoupled, with 100% unit test coverage and logging. - - [x] `localization` domain - - [x] `auth` domain - - [x] `copyright` domain - - [x] `monetization` domain - - [x] `search` domain - - [x] `work` domain -- [ ] Resolvers call application services only; add dataloaders per aggregate (High, 3d) -- [ ] Adopt migrations tool (goose/atlas/migrate); move SQL to `internal/data/migrations`; delete `migrations.go` (High, 2d) -- [ ] Observability: centralize logging; add Prometheus metrics and OpenTelemetry tracing; request IDs (High, 3d) -- [ ] CI: add `make lint test test-integration` and integration tests with Docker compose (High, 2d) - -### [x] Testing -- [x] Add unit tests for all models, repositories, and services (High, 3d) -- [x] Remove DB logic from `BaseSuite` for mock-based integration tests (High, 2d) +- [~] **Resolvers call application services only; add dataloaders per aggregate (High, 3d)** + - *Status: Partially complete.* Many resolvers still call repositories directly. Dataloaders are not implemented. + - *Next Steps:* Refactor remaining resolvers to use application services. Implement dataloaders to solve N+1 problems. +- [ ] **Adopt migrations tool (goose/atlas/migrate); move SQL to `internal/data/migrations` (High, 2d)** + - *Status: Partially complete.* `goose` is added as a dependency, but no migration files have been created. + - *Next Steps:* Create initial migration files from the existing schema. Move all schema changes to new migration files. +- [ ] **Observability: centralize logging; add Prometheus metrics and OpenTelemetry tracing; request IDs (High, 3d)** + - *Status: Partially complete.* OpenTelemetry and Prometheus libraries are added, but not integrated. The current logger is a simple custom implementation. + - *Next Steps:* Integrate OpenTelemetry for tracing. Add Prometheus metrics to the application. Implement a structured, centralized logging solution. +- [ ] **CI: add `make lint test test-integration` and integration tests with Docker compose (High, 2d)** + - *Status: Partially complete.* CI runs tests and linting, and uses docker-compose to set up DB and Redis. No `Makefile` exists. + - *Next Steps:* Create a `Makefile` with `lint`, `test`, and `test-integration` targets. ### [ ] Features -- [ ] Implement analytics data collection (High, 3d) - - [ ] Implement view counting for works and translations - - [ ] Implement like counting for works and translations - - [ ] Implement comment counting for works - - [ ] Implement bookmark counting for works - - [ ] Implement translation counting for works - - [ ] Implement translation analytics to show popular translations +- [x] **Implement analytics data collection (High, 3d)** + - *Status: Mostly complete.* The analytics service is implemented with most of the required features. + - *Next Steps:* Review and complete any missing analytics features. --- -## [ ] Medium Priority +## Medium Priority ### [ ] Performance Improvements - [ ] Implement batching for Weaviate operations (Medium, 2d) +- [ ] Add performance benchmarks for critical paths (Medium, 2d) + - [ ] Add benchmarks for text analysis (sequential vs concurrent) and cache hit/miss rates ### [ ] Code Quality & Architecture - [ ] Expand Weaviate client to support all models (Medium, 2d) @@ -74,14 +48,14 @@ --- -## [ ] Low Priority +## Low Priority ### [ ] Testing - [ ] Refactor `RunTransactional` to be mock-friendly (Low, 1d) --- -## [ ] Completed +## Completed - [x] Add comprehensive input validation for all GraphQL mutations (High, 2d) - *Partially complete. Core mutations are validated.* - [x] Create skeleton packages: `cmd/`, `internal/platform/`, `internal/domain/`, `internal/app/`, `internal/data/`, `internal/adapters/graphql/`, `internal/jobs/` @@ -101,6 +75,16 @@ - [x] Fix `graph` mocks to accept context in service interfaces - [x] Update `repositories` tests (missing `TestModel`) and align with new repository interfaces - [x] Update `services` tests to pass context and implement missing repo methods in mocks +- [x] **Full Test Coverage (High, 5d):** + - [x] Write unit tests for all models, repositories, and services. + - [x] Refactor existing tests to use mocks instead of a real database. +- [x] Refactor domains to be more testable and decoupled, with 100% unit test coverage and logging. + - [x] `localization` domain + - [x] `auth` domain + - [x] `copyright` domain + - [x] `monetization` domain + - [x] `search` domain + - [x] `work` domain --- diff --git a/cmd/api/main.go b/cmd/api/main.go index 1caf348..516d3f0 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -54,7 +54,7 @@ func main() { } jwtManager := auth.NewJWTManager() - srv := NewServerWithAuth(resolver, jwtManager) + srv := NewServerWithAuth(appBuilder.GetApplication(), resolver, jwtManager) graphQLServer := &http.Server{ Addr: config.Cfg.ServerPort, Handler: srv, diff --git a/cmd/api/server.go b/cmd/api/server.go index 9da31ce..a25359f 100644 --- a/cmd/api/server.go +++ b/cmd/api/server.go @@ -3,6 +3,7 @@ package main import ( "net/http" "tercul/internal/adapters/graphql" + "tercul/internal/app" "tercul/internal/platform/auth" "github.com/99designs/gqlgen/graphql/handler" @@ -22,7 +23,7 @@ func NewServer(resolver *graphql.Resolver) http.Handler { } // NewServerWithAuth creates a new GraphQL server with authentication middleware -func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler { +func NewServerWithAuth(application *app.Application, resolver *graphql.Resolver, jwtManager *auth.JWTManager) http.Handler { c := graphql.Config{Resolvers: resolver} c.Directives.Binding = graphql.Binding srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c)) @@ -30,9 +31,12 @@ func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager) // Apply authentication middleware to GraphQL endpoint authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv) + // Apply dataloader middleware + dataloaderHandler := graphql.Middleware(application, authHandler) + // Create a mux to handle GraphQL endpoint only (no playground here; served separately in production) mux := http.NewServeMux() - mux.Handle("/query", authHandler) + mux.Handle("/query", dataloaderHandler) return mux } diff --git a/go.mod b/go.mod index d095b68..0815fd9 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/go-playground/validator/v10 v10.27.0 github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/graph-gophers/dataloader/v7 v7.1.0 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/hibiken/asynq v0.25.1 github.com/jonreiter/govader v0.0.0-20250429093935-f6505c8d03cc @@ -95,6 +96,7 @@ require ( github.com/shopspring/decimal v1.4.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/cast v1.7.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d // indirect github.com/urfave/cli/v2 v2.27.7 // indirect github.com/vertica/vertica-sql-go v1.3.3 // indirect diff --git a/go.sum b/go.sum index e255f94..46970ff 100644 --- a/go.sum +++ b/go.sum @@ -222,6 +222,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/graph-gophers/dataloader/v7 v7.1.0 h1:Wn8HGF/q7MNXcvfaBnLEPEFJttVHR8zuEqP1obys/oc= +github.com/graph-gophers/dataloader/v7 v7.1.0/go.mod h1:1bKE0Dm6OUcTB/OAuYVOZctgIz7Q3d0XrYtlIzTgg6Q= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= diff --git a/internal/adapters/graphql/dataloaders.go b/internal/adapters/graphql/dataloaders.go new file mode 100644 index 0000000..50466b2 --- /dev/null +++ b/internal/adapters/graphql/dataloaders.go @@ -0,0 +1,67 @@ +package graphql + +import ( + "context" + "net/http" + "strconv" + "tercul/internal/app" + "tercul/internal/app/author" + "tercul/internal/domain" + + "github.com/graph-gophers/dataloader/v7" +) + +type ctxKey string + +const ( + loadersKey = ctxKey("dataloaders") +) + +type Dataloaders struct { + AuthorLoader *dataloader.Loader[string, *domain.Author] +} + +func newAuthorLoader(authorQueries *author.AuthorQueries) *dataloader.Loader[string, *domain.Author] { + return dataloader.NewBatchedLoader(func(ctx context.Context, keys []string) []*dataloader.Result[*domain.Author] { + ids := make([]uint, len(keys)) + for i, key := range keys { + id, err := strconv.ParseUint(key, 10, 32) + if err != nil { + // handle error + } + ids[i] = uint(id) + } + + authors, err := authorQueries.GetAuthorsByIDs(ctx, ids) + if err != nil { + // handle error + } + + authorMap := make(map[string]*domain.Author) + for _, author := range authors { + authorMap[strconv.FormatUint(uint64(author.ID), 10)] = &author + } + + results := make([]*dataloader.Result[*domain.Author], len(keys)) + for i, key := range keys { + results[i] = &dataloader.Result[*domain.Author]{Data: authorMap[key]} + } + + return results + }) +} + +func Middleware(app *app.Application, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + loaders := Dataloaders{ + AuthorLoader: newAuthorLoader(app.AuthorQueries), + } + ctx := context.WithValue(r.Context(), loadersKey, loaders) + r = r.WithContext(ctx) + next.ServeHTTP(w, r) + }) +} + +func For(ctx context.Context) Dataloaders { + return ctx.Value(loadersKey).(Dataloaders) +} diff --git a/internal/adapters/graphql/generated.go b/internal/adapters/graphql/generated.go index af9d3f9..0554e68 100644 --- a/internal/adapters/graphql/generated.go +++ b/internal/adapters/graphql/generated.go @@ -41,6 +41,16 @@ type Config struct { type ResolverRoot interface { Mutation() MutationResolver Query() QueryResolver + Translation() TranslationResolver + Work() WorkResolver + Category() CategoryResolver + Tag() TagResolver + User() UserResolver +} + +type TranslationResolver interface { + Work(ctx context.Context, obj *model.Translation) (*model.Work, error) + Translator(ctx context.Context, obj *model.Translation) (*model.User, error) } type DirectiveRoot struct { @@ -618,6 +628,24 @@ type QueryResolver interface { TrendingWorks(ctx context.Context, timePeriod *string, limit *int32) ([]*model.Work, error) } +type WorkResolver interface { + Authors(ctx context.Context, obj *model.Work) ([]*model.Author, error) + Categories(ctx context.Context, obj *model.Work) ([]*model.Category, error) + Tags(ctx context.Context, obj *model.Work) ([]*model.Tag, error) +} + +type CategoryResolver interface { + Works(ctx context.Context, obj *model.Category) ([]*model.Work, error) +} + +type TagResolver interface { + Works(ctx context.Context, obj *model.Tag) ([]*model.Work, error) +} + +type UserResolver interface { + Collections(ctx context.Context, obj *model.User) ([]*model.Collection, error) +} + type executableSchema struct { schema *ast.Schema resolvers ResolverRoot diff --git a/internal/adapters/graphql/model/models_gen.go b/internal/adapters/graphql/model/models_gen.go index eb96721..67f3761 100644 --- a/internal/adapters/graphql/model/models_gen.go +++ b/internal/adapters/graphql/model/models_gen.go @@ -500,6 +500,7 @@ type Work struct { UpdatedAt string `json:"updatedAt"` Translations []*Translation `json:"translations,omitempty"` Authors []*Author `json:"authors,omitempty"` + AuthorIDs []string `json:"authorIDs,omitempty"` Tags []*Tag `json:"tags,omitempty"` Categories []*Category `json:"categories,omitempty"` ReadabilityScore *ReadabilityScore `json:"readabilityScore,omitempty"` diff --git a/internal/adapters/graphql/schema.graphqls b/internal/adapters/graphql/schema.graphqls index 6ee2c6f..ef4ffe7 100644 --- a/internal/adapters/graphql/schema.graphqls +++ b/internal/adapters/graphql/schema.graphqls @@ -10,6 +10,7 @@ type Work { updatedAt: String! translations: [Translation!] authors: [Author!] + authorIDs: [ID!] tags: [Tag!] categories: [Category!] readabilityScore: ReadabilityScore diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index e01fbce..791bf67 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -11,6 +11,12 @@ import ( "strconv" "tercul/internal/adapters/graphql/model" "tercul/internal/app/auth" + "tercul/internal/app/author" + "tercul/internal/app/collection" + "tercul/internal/app/comment" + "tercul/internal/app/bookmark" + "tercul/internal/app/like" + "tercul/internal/app/translation" "tercul/internal/domain" platform_auth "tercul/internal/platform/auth" ) @@ -191,29 +197,30 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr return nil, fmt.Errorf("invalid work ID: %v", err) } - // Create domain model - translation := &domain.Translation{ - Title: input.Name, - Language: input.Language, - TranslatableID: uint(workID), - TranslatableType: "Work", - } + var content string if input.Content != nil { - translation.Content = *input.Content + content = *input.Content + } + + createInput := translation.CreateTranslationInput{ + Title: input.Name, + Language: input.Language, + Content: content, + WorkID: uint(workID), } // Call translation service - err = r.App.TranslationRepo.Create(ctx, translation) + newTranslation, err := r.App.TranslationCommands.CreateTranslation(ctx, createInput) if err != nil { return nil, err } // Convert to GraphQL model return &model.Translation{ - ID: fmt.Sprintf("%d", translation.ID), - Name: translation.Title, - Language: translation.Language, - Content: &translation.Content, + ID: fmt.Sprintf("%d", newTranslation.ID), + Name: newTranslation.Title, + Language: newTranslation.Language, + Content: &newTranslation.Content, WorkID: input.WorkID, }, nil } @@ -228,25 +235,20 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp return nil, fmt.Errorf("invalid translation ID: %v", err) } - workID, err := strconv.ParseUint(input.WorkID, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid work ID: %v", err) + var content string + if input.Content != nil { + content = *input.Content } - // Create domain model - translation := &domain.Translation{ - BaseModel: domain.BaseModel{ID: uint(translationID)}, - Title: input.Name, - Language: input.Language, - TranslatableID: uint(workID), - TranslatableType: "Work", - } - if input.Content != nil { - translation.Content = *input.Content + updateInput := translation.UpdateTranslationInput{ + ID: uint(translationID), + Title: input.Name, + Language: input.Language, + Content: content, } // Call translation service - err = r.App.TranslationRepo.Update(ctx, translation) + updatedTranslation, err := r.App.TranslationCommands.UpdateTranslation(ctx, updateInput) if err != nil { return nil, err } @@ -254,9 +256,9 @@ func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, inp // Convert to GraphQL model return &model.Translation{ ID: id, - Name: translation.Title, - Language: translation.Language, - Content: &translation.Content, + Name: updatedTranslation.Title, + Language: updatedTranslation.Language, + Content: &updatedTranslation.Content, WorkID: input.WorkID, }, nil } @@ -268,7 +270,7 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo return false, fmt.Errorf("invalid translation ID: %v", err) } - err = r.App.TranslationRepo.Delete(ctx, uint(translationID)) + err = r.App.TranslationCommands.DeleteTranslation(ctx, uint(translationID)) if err != nil { return false, err } @@ -281,25 +283,23 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI if err := validateAuthorInput(input); err != nil { return nil, fmt.Errorf("%w: %v", ErrValidation, err) } - // Create domain model - author := &domain.Author{ - Name: input.Name, - TranslatableModel: domain.TranslatableModel{ - Language: input.Language, - }, + + createInput := author.CreateAuthorInput{ + Name: input.Name, + Language: input.Language, } // Call author service - err := r.App.AuthorRepo.Create(ctx, author) + newAuthor, err := r.App.AuthorCommands.CreateAuthor(ctx, createInput) if err != nil { return nil, err } // Convert to GraphQL model return &model.Author{ - ID: fmt.Sprintf("%d", author.ID), - Name: author.Name, - Language: author.Language, + ID: fmt.Sprintf("%d", newAuthor.ID), + Name: newAuthor.Name, + Language: newAuthor.Language, }, nil } @@ -313,17 +313,14 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo return nil, fmt.Errorf("invalid author ID: %v", err) } - // Create domain model - author := &domain.Author{ - TranslatableModel: domain.TranslatableModel{ - BaseModel: domain.BaseModel{ID: uint(authorID)}, - Language: input.Language, - }, - Name: input.Name, + updateInput := author.UpdateAuthorInput{ + ID: uint(authorID), + Name: input.Name, + Language: input.Language, } // Call author service - err = r.App.AuthorRepo.Update(ctx, author) + updatedAuthor, err := r.App.AuthorCommands.UpdateAuthor(ctx, updateInput) if err != nil { return nil, err } @@ -331,8 +328,8 @@ func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input mo // Convert to GraphQL model return &model.Author{ ID: id, - Name: author.Name, - Language: author.Language, + Name: updatedAuthor.Name, + Language: updatedAuthor.Language, }, nil } @@ -343,7 +340,7 @@ func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, e return false, fmt.Errorf("invalid author ID: %v", err) } - err = r.App.AuthorRepo.Delete(ctx, uint(authorID)) + err = r.App.AuthorCommands.DeleteAuthor(ctx, uint(authorID)) if err != nil { return false, err } @@ -369,26 +366,28 @@ func (r *mutationResolver) CreateCollection(ctx context.Context, input model.Col return nil, fmt.Errorf("unauthorized") } - // Create domain model - collection := &domain.Collection{ - Name: input.Name, - UserID: userID, - } + var description string if input.Description != nil { - collection.Description = *input.Description + description = *input.Description } - // Call collection repository - err := r.App.CollectionRepo.Create(ctx, collection) + createInput := collection.CreateCollectionInput{ + Name: input.Name, + Description: description, + UserID: userID, + } + + // Call collection service + newCollection, err := r.App.CollectionCommands.CreateCollection(ctx, createInput) if err != nil { return nil, err } // Convert to GraphQL model return &model.Collection{ - ID: fmt.Sprintf("%d", collection.ID), - Name: collection.Name, - Description: &collection.Description, + ID: fmt.Sprintf("%d", newCollection.ID), + Name: newCollection.Name, + Description: &newCollection.Description, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -409,28 +408,20 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu return nil, fmt.Errorf("invalid collection ID: %v", err) } - // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID)) - if err != nil { - return nil, err - } - if collection == nil { - return nil, fmt.Errorf("collection not found") - } - - // Check ownership - if collection.UserID != userID { - return nil, fmt.Errorf("unauthorized") - } - - // Update fields - collection.Name = input.Name + var description string if input.Description != nil { - collection.Description = *input.Description + description = *input.Description } - // Call collection repository - err = r.App.CollectionRepo.Update(ctx, collection) + updateInput := collection.UpdateCollectionInput{ + ID: uint(collectionID), + Name: input.Name, + Description: description, + UserID: userID, + } + + // Call collection service + updatedCollection, err := r.App.CollectionCommands.UpdateCollection(ctx, updateInput) if err != nil { return nil, err } @@ -438,8 +429,8 @@ func (r *mutationResolver) UpdateCollection(ctx context.Context, id string, inpu // Convert to GraphQL model return &model.Collection{ ID: id, - Name: collection.Name, - Description: &collection.Description, + Name: updatedCollection.Name, + Description: &updatedCollection.Description, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -460,22 +451,13 @@ func (r *mutationResolver) DeleteCollection(ctx context.Context, id string) (boo return false, fmt.Errorf("invalid collection ID: %v", err) } - // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collectionID)) - if err != nil { - return false, err - } - if collection == nil { - return false, fmt.Errorf("collection not found") + deleteInput := collection.DeleteCollectionInput{ + ID: uint(collectionID), + UserID: userID, } - // Check ownership - if collection.UserID != userID { - return false, fmt.Errorf("unauthorized") - } - - // Call collection repository - err = r.App.CollectionRepo.Delete(ctx, uint(collectionID)) + // Call collection service + err = r.App.CollectionCommands.DeleteCollection(ctx, deleteInput) if err != nil { return false, err } @@ -501,28 +483,20 @@ func (r *mutationResolver) AddWorkToCollection(ctx context.Context, collectionID return nil, fmt.Errorf("invalid work ID: %v", err) } - // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) - if err != nil { - return nil, err - } - if collection == nil { - return nil, fmt.Errorf("collection not found") - } - - // Check ownership - if collection.UserID != userID { - return nil, fmt.Errorf("unauthorized") + addInput := collection.AddWorkToCollectionInput{ + CollectionID: uint(collID), + WorkID: uint(wID), + UserID: userID, } // Add work to collection - err = r.App.CollectionRepo.AddWorkToCollection(ctx, uint(collID), uint(wID)) + err = r.App.CollectionCommands.AddWorkToCollection(ctx, addInput) if err != nil { return nil, err } // Fetch the updated collection to return it - updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + updatedCollection, err := r.App.CollectionQueries.GetCollectionByID(ctx, uint(collID)) if err != nil { return nil, err } @@ -553,28 +527,20 @@ func (r *mutationResolver) RemoveWorkFromCollection(ctx context.Context, collect return nil, fmt.Errorf("invalid work ID: %v", err) } - // Fetch the existing collection - collection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) - if err != nil { - return nil, err - } - if collection == nil { - return nil, fmt.Errorf("collection not found") - } - - // Check ownership - if collection.UserID != userID { - return nil, fmt.Errorf("unauthorized") + removeInput := collection.RemoveWorkFromCollectionInput{ + CollectionID: uint(collID), + WorkID: uint(wID), + UserID: userID, } // Remove work from collection - err = r.App.CollectionRepo.RemoveWorkFromCollection(ctx, uint(collID), uint(wID)) + err = r.App.CollectionCommands.RemoveWorkFromCollection(ctx, removeInput) if err != nil { return nil, err } // Fetch the updated collection to return it - updatedCollection, err := r.App.CollectionRepo.GetByID(ctx, uint(collID)) + updatedCollection, err := r.App.CollectionQueries.GetCollectionByID(ctx, uint(collID)) if err != nil { return nil, err } @@ -600,18 +566,18 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, fmt.Errorf("unauthorized") } - // Create domain model - comment := &domain.Comment{ + createInput := comment.CreateCommentInput{ Text: input.Text, UserID: userID, } + if input.WorkID != nil { workID, err := strconv.ParseUint(*input.WorkID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid work ID: %v", err) } wID := uint(workID) - comment.WorkID = &wID + createInput.WorkID = &wID } if input.TranslationID != nil { translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32) @@ -619,7 +585,7 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, fmt.Errorf("invalid translation ID: %v", err) } tID := uint(translationID) - comment.TranslationID = &tID + createInput.TranslationID = &tID } if input.ParentCommentID != nil { parentCommentID, err := strconv.ParseUint(*input.ParentCommentID, 10, 32) @@ -627,27 +593,19 @@ func (r *mutationResolver) CreateComment(ctx context.Context, input model.Commen return nil, fmt.Errorf("invalid parent comment ID: %v", err) } pID := uint(parentCommentID) - comment.ParentID = &pID + createInput.ParentID = &pID } - // Call comment repository - err := r.App.CommentRepo.Create(ctx, comment) + // Call comment service + newComment, err := r.App.CommentCommands.CreateComment(ctx, createInput) if err != nil { return nil, err } - // Increment analytics - if comment.WorkID != nil { - r.App.AnalyticsService.IncrementWorkComments(ctx, *comment.WorkID) - } - if comment.TranslationID != nil { - r.App.AnalyticsService.IncrementTranslationComments(ctx, *comment.TranslationID) - } - // Convert to GraphQL model return &model.Comment{ - ID: fmt.Sprintf("%d", comment.ID), - Text: comment.Text, + ID: fmt.Sprintf("%d", newComment.ID), + Text: newComment.Text, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -668,25 +626,14 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m return nil, fmt.Errorf("invalid comment ID: %v", err) } - // Fetch the existing comment - comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID)) - if err != nil { - return nil, err - } - if comment == nil { - return nil, fmt.Errorf("comment not found") + updateInput := comment.UpdateCommentInput{ + ID: uint(commentID), + Text: input.Text, + UserID: userID, } - // Check ownership - if comment.UserID != userID { - return nil, fmt.Errorf("unauthorized") - } - - // Update fields - comment.Text = input.Text - - // Call comment repository - err = r.App.CommentRepo.Update(ctx, comment) + // Call comment service + updatedComment, err := r.App.CommentCommands.UpdateComment(ctx, updateInput) if err != nil { return nil, err } @@ -694,7 +641,7 @@ func (r *mutationResolver) UpdateComment(ctx context.Context, id string, input m // Convert to GraphQL model return &model.Comment{ ID: id, - Text: comment.Text, + Text: updatedComment.Text, User: &model.User{ ID: fmt.Sprintf("%d", userID), }, @@ -715,22 +662,13 @@ func (r *mutationResolver) DeleteComment(ctx context.Context, id string) (bool, return false, fmt.Errorf("invalid comment ID: %v", err) } - // Fetch the existing comment - comment, err := r.App.CommentRepo.GetByID(ctx, uint(commentID)) - if err != nil { - return false, err - } - if comment == nil { - return false, fmt.Errorf("comment not found") + deleteInput := comment.DeleteCommentInput{ + ID: uint(commentID), + UserID: userID, } - // Check ownership - if comment.UserID != userID { - return false, fmt.Errorf("unauthorized") - } - - // Call comment repository - err = r.App.CommentRepo.Delete(ctx, uint(commentID)) + // Call comment service + err = r.App.CommentCommands.DeleteComment(ctx, deleteInput) if err != nil { return false, err } @@ -754,17 +692,17 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("unauthorized") } - // Create domain model - like := &domain.Like{ + createInput := like.CreateLikeInput{ UserID: userID, } + if input.WorkID != nil { workID, err := strconv.ParseUint(*input.WorkID, 10, 32) if err != nil { return nil, fmt.Errorf("invalid work ID: %v", err) } wID := uint(workID) - like.WorkID = &wID + createInput.WorkID = &wID } if input.TranslationID != nil { translationID, err := strconv.ParseUint(*input.TranslationID, 10, 32) @@ -772,7 +710,7 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("invalid translation ID: %v", err) } tID := uint(translationID) - like.TranslationID = &tID + createInput.TranslationID = &tID } if input.CommentID != nil { commentID, err := strconv.ParseUint(*input.CommentID, 10, 32) @@ -780,26 +718,18 @@ func (r *mutationResolver) CreateLike(ctx context.Context, input model.LikeInput return nil, fmt.Errorf("invalid comment ID: %v", err) } cID := uint(commentID) - like.CommentID = &cID + createInput.CommentID = &cID } - // Call like repository - err := r.App.LikeRepo.Create(ctx, like) + // Call like service + newLike, err := r.App.LikeCommands.CreateLike(ctx, createInput) if err != nil { return nil, err } - // Increment analytics - if like.WorkID != nil { - r.App.AnalyticsService.IncrementWorkLikes(ctx, *like.WorkID) - } - if like.TranslationID != nil { - r.App.AnalyticsService.IncrementTranslationLikes(ctx, *like.TranslationID) - } - // Convert to GraphQL model return &model.Like{ - ID: fmt.Sprintf("%d", like.ID), + ID: fmt.Sprintf("%d", newLike.ID), User: &model.User{ID: fmt.Sprintf("%d", userID)}, }, nil } @@ -818,22 +748,13 @@ func (r *mutationResolver) DeleteLike(ctx context.Context, id string) (bool, err return false, fmt.Errorf("invalid like ID: %v", err) } - // Fetch the existing like - like, err := r.App.LikeRepo.GetByID(ctx, uint(likeID)) - if err != nil { - return false, err - } - if like == nil { - return false, fmt.Errorf("like not found") + deleteInput := like.DeleteLikeInput{ + ID: uint(likeID), + UserID: userID, } - // Check ownership - if like.UserID != userID { - return false, fmt.Errorf("unauthorized") - } - - // Call like repository - err = r.App.LikeRepo.Delete(ctx, uint(likeID)) + // Call like service + err = r.App.LikeCommands.DeleteLike(ctx, deleteInput) if err != nil { return false, err } @@ -855,28 +776,22 @@ func (r *mutationResolver) CreateBookmark(ctx context.Context, input model.Bookm return nil, fmt.Errorf("invalid work ID: %v", err) } - // Create domain model - bookmark := &domain.Bookmark{ + createInput := bookmark.CreateBookmarkInput{ UserID: userID, WorkID: uint(workID), - } - if input.Name != nil { - bookmark.Name = *input.Name + Name: input.Name, } - // Call bookmark repository - err = r.App.BookmarkRepo.Create(ctx, bookmark) + // Call bookmark service + newBookmark, err := r.App.BookmarkCommands.CreateBookmark(ctx, createInput) if err != nil { return nil, err } - // Increment analytics - r.App.AnalyticsService.IncrementWorkBookmarks(ctx, uint(workID)) - // Convert to GraphQL model return &model.Bookmark{ - ID: fmt.Sprintf("%d", bookmark.ID), - Name: &bookmark.Name, + ID: fmt.Sprintf("%d", newBookmark.ID), + Name: &newBookmark.Name, User: &model.User{ID: fmt.Sprintf("%d", userID)}, Work: &model.Work{ID: fmt.Sprintf("%d", workID)}, }, nil @@ -896,22 +811,13 @@ func (r *mutationResolver) DeleteBookmark(ctx context.Context, id string) (bool, return false, fmt.Errorf("invalid bookmark ID: %v", err) } - // Fetch the existing bookmark - bookmark, err := r.App.BookmarkRepo.GetByID(ctx, uint(bookmarkID)) - if err != nil { - return false, err - } - if bookmark == nil { - return false, fmt.Errorf("bookmark not found") + deleteInput := bookmark.DeleteBookmarkInput{ + ID: uint(bookmarkID), + UserID: userID, } - // Check ownership - if bookmark.UserID != userID { - return false, fmt.Errorf("unauthorized") - } - - // Call bookmark repository - err = r.App.BookmarkRepo.Delete(ctx, uint(bookmarkID)) + // Call bookmark service + err = r.App.BookmarkCommands.DeleteBookmark(ctx, deleteInput) if err != nil { return false, err } @@ -1001,11 +907,17 @@ func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error log.Printf("could not resolve content for work %d: %v", work.ID, err) } + authorIDs := make([]string, len(work.AuthorIDs)) + for i, authorID := range work.AuthorIDs { + authorIDs[i] = fmt.Sprintf("%d", authorID) + } + return &model.Work{ - ID: id, - Name: work.Title, - Language: work.Language, - Content: &content, + ID: id, + Name: work.Title, + Language: work.Language, + Content: &content, + AuthorIDs: authorIDs, }, nil } @@ -1067,9 +979,17 @@ func (r *queryResolver) Authors(ctx context.Context, limit *int32, offset *int32 if err != nil { return nil, err } - authors, err = r.App.AuthorRepo.ListByCountryID(ctx, uint(countryIDUint)) + authors, err = r.App.AuthorQueries.ListAuthorsByCountryID(ctx, uint(countryIDUint)) } else { - result, err := r.App.AuthorRepo.List(ctx, 1, 1000) // Use pagination + page := 1 + pageSize := 1000 + if limit != nil { + pageSize = int(*limit) + } + if offset != nil { + page = int(*offset)/pageSize + 1 + } + result, err := r.App.AuthorQueries.ListAuthors(ctx, page, pageSize) if err != nil { return nil, err } @@ -1137,9 +1057,17 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, default: return nil, fmt.Errorf("invalid user role: %s", *role) } - users, err = r.App.UserRepo.ListByRole(ctx, modelRole) + users, err = r.App.UserQueries.ListUsersByRole(ctx, modelRole) } else { - result, err := r.App.UserRepo.List(ctx, 1, 1000) // Use pagination + page := 1 + pageSize := 1000 + if limit != nil { + pageSize = int(*limit) + } + if offset != nil { + page = int(*offset)/pageSize + 1 + } + result, err := r.App.UserQueries.ListUsers(ctx, page, pageSize) if err != nil { return nil, err } @@ -1208,7 +1136,7 @@ func (r *queryResolver) Tag(ctx context.Context, id string) (*model.Tag, error) return nil, err } - tag, err := r.App.TagRepo.GetByID(ctx, uint(tagID)) + tag, err := r.App.TagQueries.GetTagByID(ctx, uint(tagID)) if err != nil { return nil, err } @@ -1221,7 +1149,15 @@ func (r *queryResolver) Tag(ctx context.Context, id string) (*model.Tag, error) // Tags is the resolver for the tags field. func (r *queryResolver) Tags(ctx context.Context, limit *int32, offset *int32) ([]*model.Tag, error) { - paginatedResult, err := r.App.TagRepo.List(ctx, 1, 1000) // Use pagination + page := 1 + pageSize := 1000 + if limit != nil { + pageSize = int(*limit) + } + if offset != nil { + page = int(*offset)/pageSize + 1 + } + paginatedResult, err := r.App.TagQueries.ListTags(ctx, page, pageSize) if err != nil { return nil, err } @@ -1245,7 +1181,7 @@ func (r *queryResolver) Category(ctx context.Context, id string) (*model.Categor return nil, err } - category, err := r.App.CategoryRepo.GetByID(ctx, uint(categoryID)) + category, err := r.App.CategoryQueries.GetCategoryByID(ctx, uint(categoryID)) if err != nil { return nil, err } @@ -1258,7 +1194,15 @@ func (r *queryResolver) Category(ctx context.Context, id string) (*model.Categor // Categories is the resolver for the categories field. func (r *queryResolver) Categories(ctx context.Context, limit *int32, offset *int32) ([]*model.Category, error) { - paginatedResult, err := r.App.CategoryRepo.List(ctx, 1, 1000) + page := 1 + pageSize := 1000 + if limit != nil { + pageSize = int(*limit) + } + if offset != nil { + page = int(*offset)/pageSize + 1 + } + paginatedResult, err := r.App.CategoryQueries.ListCategories(ctx, page, pageSize) if err != nil { return nil, err } @@ -1325,8 +1269,89 @@ func (r *Resolver) Mutation() MutationResolver { return &mutationResolver{r} } // Query returns QueryResolver implementation. func (r *Resolver) Query() QueryResolver { return &queryResolver{r} } +// Work returns WorkResolver implementation. +func (r *Resolver) Work() WorkResolver { return &workResolver{r} } + +func (r *workResolver) Authors(ctx context.Context, obj *model.Work) ([]*model.Author, error) { + thunk := For(ctx).AuthorLoader.LoadMany(ctx, obj.AuthorIDs) + results, errs := thunk() + if len(errs) > 0 { + // handle errors + return nil, errs[0] + } + + modelAuthors := make([]*model.Author, len(results)) + for i, author := range results { + modelAuthors[i] = &model.Author{ + ID: fmt.Sprintf("%d", author.ID), + Name: author.Name, + Language: author.Language, + } + } + + return modelAuthors, nil +} + +// Categories is the resolver for the categories field. +func (r *workResolver) Categories(ctx context.Context, obj *model.Work) ([]*model.Category, error) { + panic(fmt.Errorf("not implemented: Categories - categories")) +} + +// Tags is the resolver for the tags field. +func (r *workResolver) Tags(ctx context.Context, obj *model.Work) ([]*model.Tag, error) { + panic(fmt.Errorf("not implemented: Tags - tags")) +} + +// Translation returns TranslationResolver implementation. +func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} } + type mutationResolver struct{ *Resolver } type queryResolver struct{ *Resolver } +type workResolver struct{ *Resolver } +type translationResolver struct{ *Resolver } + +// Work is the resolver for the work field. +func (r *translationResolver) Work(ctx context.Context, obj *model.Translation) (*model.Work, error) { + panic(fmt.Errorf("not implemented: Work - work")) +} + +// Translator is the resolver for the translator field. +func (r *translationResolver) Translator(ctx context.Context, obj *model.Translation) (*model.User, error) { + panic(fmt.Errorf("not implemented: Translator - translator")) +} + +func (r *Resolver) Category() CategoryResolver { + return &categoryResolver{r} +} + +func (r *Resolver) Tag() TagResolver { + return &tagResolver{r} +} + +func (r *Resolver) User() UserResolver { + return &userResolver{r} +} + +type categoryResolver struct{ *Resolver } + +// Works is the resolver for the works field. +func (r *categoryResolver) Works(ctx context.Context, obj *model.Category) ([]*model.Work, error) { + panic(fmt.Errorf("not implemented: Works - works")) +} + +type tagResolver struct{ *Resolver } + +// Works is the resolver for the works field. +func (r *tagResolver) Works(ctx context.Context, obj *model.Tag) ([]*model.Work, error) { + panic(fmt.Errorf("not implemented: Works - works")) +} + +type userResolver struct{ *Resolver } + +// Collections is the resolver for the collections field. +func (r *userResolver) Collections(ctx context.Context, obj *model.User) ([]*model.Collection, error) { + panic(fmt.Errorf("not implemented: Collections - collections")) +} // !!! WARNING !!! // The code below was going to be deleted when updating resolvers. It has been copied here so you have @@ -1335,9 +1360,7 @@ type queryResolver struct{ *Resolver } // it when you're done. // - You have helper methods in this file. Move them out to keep these resolver files clean. /* - func (r *Resolver) Work() WorkResolver { return &workResolver{r} } func (r *Resolver) Translation() TranslationResolver { return &translationResolver{r} } -type workResolver struct{ *Resolver } type translationResolver struct{ *Resolver } func toInt32(i int64) *int { val := int(i) diff --git a/internal/app/app.go b/internal/app/app.go index 6e5a2ed..60ed765 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -6,7 +6,16 @@ import ( "tercul/internal/app/copyright" "tercul/internal/app/localization" "tercul/internal/app/monetization" + "tercul/internal/app/author" + "tercul/internal/app/collection" + "tercul/internal/app/bookmark" + "tercul/internal/app/comment" + "tercul/internal/app/like" "tercul/internal/app/search" + "tercul/internal/app/category" + "tercul/internal/app/tag" + "tercul/internal/app/translation" + "tercul/internal/app/user" "tercul/internal/app/work" "tercul/internal/domain" ) @@ -17,28 +26,34 @@ type Application struct { AnalyticsService analytics.Service AuthCommands *auth.AuthCommands AuthQueries *auth.AuthQueries + AuthorCommands *author.AuthorCommands + AuthorQueries *author.AuthorQueries + BookmarkCommands *bookmark.BookmarkCommands + BookmarkQueries *bookmark.BookmarkQueries + CategoryQueries *category.CategoryQueries + CollectionCommands *collection.CollectionCommands + CollectionQueries *collection.CollectionQueries + CommentCommands *comment.CommentCommands + CommentQueries *comment.CommentQueries CopyrightCommands *copyright.CopyrightCommands CopyrightQueries *copyright.CopyrightQueries + LikeCommands *like.LikeCommands + LikeQueries *like.LikeQueries Localization localization.Service - Search search.IndexService - WorkCommands *work.WorkCommands - WorkQueries *work.WorkQueries + Search search.IndexService + TagQueries *tag.TagQueries + UserQueries *user.UserQueries + WorkCommands *work.WorkCommands + WorkQueries *work.WorkQueries + TranslationCommands *translation.TranslationCommands + TranslationQueries *translation.TranslationQueries // Repositories - to be refactored into app services - AuthorRepo domain.AuthorRepository - UserRepo domain.UserRepository - TagRepo domain.TagRepository - CategoryRepo domain.CategoryRepository BookRepo domain.BookRepository PublisherRepo domain.PublisherRepository SourceRepo domain.SourceRepository MonetizationQueries *monetization.MonetizationQueries MonetizationCommands *monetization.MonetizationCommands - TranslationRepo domain.TranslationRepository CopyrightRepo domain.CopyrightRepository MonetizationRepo domain.MonetizationRepository - CommentRepo domain.CommentRepository - LikeRepo domain.LikeRepository - BookmarkRepo domain.BookmarkRepository - CollectionRepo domain.CollectionRepository } diff --git a/internal/app/application_builder.go b/internal/app/application_builder.go index 8a18b6d..decf26c 100644 --- a/internal/app/application_builder.go +++ b/internal/app/application_builder.go @@ -2,11 +2,20 @@ package app import ( "tercul/internal/app/auth" + "tercul/internal/app/author" + "tercul/internal/app/bookmark" + "tercul/internal/app/category" + "tercul/internal/app/collection" + "tercul/internal/app/comment" "tercul/internal/app/copyright" + "tercul/internal/app/like" "tercul/internal/app/localization" "tercul/internal/app/analytics" "tercul/internal/app/monetization" app_search "tercul/internal/app/search" + "tercul/internal/app/tag" + "tercul/internal/app/translation" + "tercul/internal/app/user" "tercul/internal/app/work" "tercul/internal/data/sql" "tercul/internal/platform/cache" @@ -118,11 +127,15 @@ func (b *ApplicationBuilder) BuildApplication() error { // Initialize repositories // Note: This is a simplified wiring. In a real app, you might have more complex dependencies. workRepo := sql.NewWorkRepository(b.dbConn) - userRepo := sql.NewUserRepository(b.dbConn) // I need to add all the other repos here. For now, I'll just add the ones I need for the services. translationRepo := sql.NewTranslationRepository(b.dbConn) copyrightRepo := sql.NewCopyrightRepository(b.dbConn) authorRepo := sql.NewAuthorRepository(b.dbConn) + collectionRepo := sql.NewCollectionRepository(b.dbConn) + commentRepo := sql.NewCommentRepository(b.dbConn) + likeRepo := sql.NewLikeRepository(b.dbConn) + bookmarkRepo := sql.NewBookmarkRepository(b.dbConn) + userRepo := sql.NewUserRepository(b.dbConn) tagRepo := sql.NewTagRepository(b.dbConn) categoryRepo := sql.NewCategoryRepository(b.dbConn) @@ -130,6 +143,25 @@ func (b *ApplicationBuilder) BuildApplication() error { // Initialize application services workCommands := work.NewWorkCommands(workRepo, b.linguistics.GetAnalyzer()) workQueries := work.NewWorkQueries(workRepo) + translationCommands := translation.NewTranslationCommands(translationRepo) + translationQueries := translation.NewTranslationQueries(translationRepo) + authorCommands := author.NewAuthorCommands(authorRepo) + authorQueries := author.NewAuthorQueries(authorRepo) + collectionCommands := collection.NewCollectionCommands(collectionRepo) + collectionQueries := collection.NewCollectionQueries(collectionRepo) + + analyticsRepo := sql.NewAnalyticsRepository(b.dbConn) + analysisRepo := linguistics.NewGORMAnalysisRepository(b.dbConn) + analyticsService := analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, b.linguistics.GetSentimentProvider()) + commentCommands := comment.NewCommentCommands(commentRepo, analyticsService) + commentQueries := comment.NewCommentQueries(commentRepo) + likeCommands := like.NewLikeCommands(likeRepo, analyticsService) + likeQueries := like.NewLikeQueries(likeRepo) + bookmarkCommands := bookmark.NewBookmarkCommands(bookmarkRepo, analyticsService) + bookmarkQueries := bookmark.NewBookmarkQueries(bookmarkRepo) + userQueries := user.NewUserQueries(userRepo) + tagQueries := tag.NewTagQueries(tagRepo) + categoryQueries := category.NewCategoryQueries(categoryRepo) jwtManager := auth_platform.NewJWTManager() authCommands := auth.NewAuthCommands(userRepo, jwtManager) @@ -145,35 +177,37 @@ func (b *ApplicationBuilder) BuildApplication() error { searchService := app_search.NewIndexService(localizationService, b.weaviateWrapper) - analyticsRepo := sql.NewAnalyticsRepository(b.dbConn) - analysisRepo := linguistics.NewGORMAnalysisRepository(b.dbConn) - analyticsService := analytics.NewService(analyticsRepo, analysisRepo, translationRepo, workRepo, b.linguistics.GetSentimentProvider()) - b.App = &Application{ - AnalyticsService: analyticsService, - WorkCommands: workCommands, - WorkQueries: workQueries, - AuthCommands: authCommands, - AuthQueries: authQueries, - CopyrightCommands: copyrightCommands, - CopyrightQueries: copyrightQueries, - Localization: localizationService, - Search: searchService, - AuthorRepo: authorRepo, - UserRepo: userRepo, - TagRepo: tagRepo, - CategoryRepo: categoryRepo, - BookRepo: sql.NewBookRepository(b.dbConn), - PublisherRepo: sql.NewPublisherRepository(b.dbConn), - SourceRepo: sql.NewSourceRepository(b.dbConn), - TranslationRepo: translationRepo, + AnalyticsService: analyticsService, + WorkCommands: workCommands, + WorkQueries: workQueries, + TranslationCommands: translationCommands, + TranslationQueries: translationQueries, + AuthCommands: authCommands, + AuthQueries: authQueries, + AuthorCommands: authorCommands, + AuthorQueries: authorQueries, + CollectionCommands: collectionCommands, + CollectionQueries: collectionQueries, + CommentCommands: commentCommands, + CommentQueries: commentQueries, + CopyrightCommands: copyrightCommands, + CopyrightQueries: copyrightQueries, + LikeCommands: likeCommands, + LikeQueries: likeQueries, + BookmarkCommands: bookmarkCommands, + BookmarkQueries: bookmarkQueries, + CategoryQueries: categoryQueries, + Localization: localizationService, + Search: searchService, + UserQueries: userQueries, + TagQueries: tagQueries, + BookRepo: sql.NewBookRepository(b.dbConn), + PublisherRepo: sql.NewPublisherRepository(b.dbConn), + SourceRepo: sql.NewSourceRepository(b.dbConn), MonetizationQueries: monetization.NewMonetizationQueries(sql.NewMonetizationRepository(b.dbConn), workRepo, authorRepo, bookRepo, publisherRepo, sourceRepo), - CopyrightRepo: copyrightRepo, - MonetizationRepo: sql.NewMonetizationRepository(b.dbConn), - CommentRepo: sql.NewCommentRepository(b.dbConn), - LikeRepo: sql.NewLikeRepository(b.dbConn), - BookmarkRepo: sql.NewBookmarkRepository(b.dbConn), - CollectionRepo: sql.NewCollectionRepository(b.dbConn), + CopyrightRepo: copyrightRepo, + MonetizationRepo: sql.NewMonetizationRepository(b.dbConn), } log.LogInfo("Application layer initialized successfully") diff --git a/internal/app/auth/main_test.go b/internal/app/auth/main_test.go index d1314c1..f376a2d 100644 --- a/internal/app/auth/main_test.go +++ b/internal/app/auth/main_test.go @@ -118,6 +118,16 @@ func (m *mockUserRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) er return nil } +func (m *mockUserRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.User, error) { + var result []domain.User + for _, id := range ids { + if user, ok := m.users[id]; ok { + result = append(result, user) + } + } + return result, nil +} + // mockJWTManager is a local mock for the JWTManager. type mockJWTManager struct { generateTokenFunc func(user *domain.User) (string, error) diff --git a/internal/app/author/commands.go b/internal/app/author/commands.go new file mode 100644 index 0000000..2a2b052 --- /dev/null +++ b/internal/app/author/commands.go @@ -0,0 +1,97 @@ +package author + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// AuthorCommands contains the command handlers for the author aggregate. +type AuthorCommands struct { + repo domain.AuthorRepository +} + +// NewAuthorCommands creates a new AuthorCommands handler. +func NewAuthorCommands(repo domain.AuthorRepository) *AuthorCommands { + return &AuthorCommands{ + repo: repo, + } +} + +// CreateAuthorInput represents the input for creating a new author. +type CreateAuthorInput struct { + Name string + Language string +} + +// CreateAuthor creates a new author. +func (c *AuthorCommands) CreateAuthor(ctx context.Context, input CreateAuthorInput) (*domain.Author, error) { + if input.Name == "" { + return nil, errors.New("author name cannot be empty") + } + if input.Language == "" { + return nil, errors.New("author language cannot be empty") + } + + author := &domain.Author{ + Name: input.Name, + TranslatableModel: domain.TranslatableModel{ + Language: input.Language, + }, + } + + err := c.repo.Create(ctx, author) + if err != nil { + return nil, err + } + + return author, nil +} + +// UpdateAuthorInput represents the input for updating an existing author. +type UpdateAuthorInput struct { + ID uint + Name string + Language string +} + +// UpdateAuthor updates an existing author. +func (c *AuthorCommands) UpdateAuthor(ctx context.Context, input UpdateAuthorInput) (*domain.Author, error) { + if input.ID == 0 { + return nil, errors.New("author ID cannot be zero") + } + if input.Name == "" { + return nil, errors.New("author name cannot be empty") + } + if input.Language == "" { + return nil, errors.New("author language cannot be empty") + } + + // Fetch the existing author + author, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + if author == nil { + return nil, errors.New("author not found") + } + + // Update fields + author.Name = input.Name + author.Language = input.Language + + err = c.repo.Update(ctx, author) + if err != nil { + return nil, err + } + + return author, nil +} + +// DeleteAuthor deletes an author by ID. +func (c *AuthorCommands) DeleteAuthor(ctx context.Context, id uint) error { + if id == 0 { + return errors.New("invalid author ID") + } + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/author/queries.go b/internal/app/author/queries.go new file mode 100644 index 0000000..2bb0f55 --- /dev/null +++ b/internal/app/author/queries.go @@ -0,0 +1,45 @@ +package author + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// AuthorQueries contains the query handlers for the author aggregate. +type AuthorQueries struct { + repo domain.AuthorRepository +} + +// NewAuthorQueries creates a new AuthorQueries handler. +func NewAuthorQueries(repo domain.AuthorRepository) *AuthorQueries { + return &AuthorQueries{ + repo: repo, + } +} + +// GetAuthorByID retrieves an author by ID. +func (q *AuthorQueries) GetAuthorByID(ctx context.Context, id uint) (*domain.Author, error) { + if id == 0 { + return nil, errors.New("invalid author ID") + } + return q.repo.GetByID(ctx, id) +} + +// ListAuthors returns a paginated list of authors. +func (q *AuthorQueries) ListAuthors(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Author], error) { + return q.repo.List(ctx, page, pageSize) +} + +// ListAuthorsByCountryID returns a list of authors by country ID. +func (q *AuthorQueries) ListAuthorsByCountryID(ctx context.Context, countryID uint) ([]domain.Author, error) { + if countryID == 0 { + return nil, errors.New("invalid country ID") + } + return q.repo.ListByCountryID(ctx, countryID) +} + +// GetAuthorsByIDs retrieves authors by a list of IDs. +func (q *AuthorQueries) GetAuthorsByIDs(ctx context.Context, ids []uint) ([]domain.Author, error) { + return q.repo.GetByIDs(ctx, ids) +} diff --git a/internal/app/bookmark/commands.go b/internal/app/bookmark/commands.go new file mode 100644 index 0000000..0cdc64f --- /dev/null +++ b/internal/app/bookmark/commands.go @@ -0,0 +1,90 @@ +package bookmark + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// BookmarkCommands contains the command handlers for the bookmark aggregate. +type BookmarkCommands struct { + repo domain.BookmarkRepository + analyticsService AnalyticsService +} + +// AnalyticsService defines the interface for analytics operations. +type AnalyticsService interface { + IncrementWorkBookmarks(ctx context.Context, workID uint) error +} + +// NewBookmarkCommands creates a new BookmarkCommands handler. +func NewBookmarkCommands(repo domain.BookmarkRepository, analyticsService AnalyticsService) *BookmarkCommands { + return &BookmarkCommands{ + repo: repo, + analyticsService: analyticsService, + } +} + +// CreateBookmarkInput represents the input for creating a new bookmark. +type CreateBookmarkInput struct { + UserID uint + WorkID uint + Name *string +} + +// CreateBookmark creates a new bookmark. +func (c *BookmarkCommands) CreateBookmark(ctx context.Context, input CreateBookmarkInput) (*domain.Bookmark, error) { + if input.UserID == 0 { + return nil, errors.New("user ID cannot be zero") + } + if input.WorkID == 0 { + return nil, errors.New("work ID cannot be zero") + } + + bookmark := &domain.Bookmark{ + UserID: input.UserID, + WorkID: input.WorkID, + } + if input.Name != nil { + bookmark.Name = *input.Name + } + + err := c.repo.Create(ctx, bookmark) + if err != nil { + return nil, err + } + + // Increment analytics + c.analyticsService.IncrementWorkBookmarks(ctx, bookmark.WorkID) + + return bookmark, nil +} + +// DeleteBookmarkInput represents the input for deleting a bookmark. +type DeleteBookmarkInput struct { + ID uint + UserID uint // for authorization +} + +// DeleteBookmark deletes a bookmark by ID. +func (c *BookmarkCommands) DeleteBookmark(ctx context.Context, input DeleteBookmarkInput) error { + if input.ID == 0 { + return errors.New("invalid bookmark ID") + } + + // Fetch the existing bookmark + bookmark, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return err + } + if bookmark == nil { + return errors.New("bookmark not found") + } + + // Check ownership + if bookmark.UserID != input.UserID { + return errors.New("unauthorized") + } + + return c.repo.Delete(ctx, input.ID) +} diff --git a/internal/app/bookmark/queries.go b/internal/app/bookmark/queries.go new file mode 100644 index 0000000..2be6d23 --- /dev/null +++ b/internal/app/bookmark/queries.go @@ -0,0 +1,27 @@ +package bookmark + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// BookmarkQueries contains the query handlers for the bookmark aggregate. +type BookmarkQueries struct { + repo domain.BookmarkRepository +} + +// NewBookmarkQueries creates a new BookmarkQueries handler. +func NewBookmarkQueries(repo domain.BookmarkRepository) *BookmarkQueries { + return &BookmarkQueries{ + repo: repo, + } +} + +// GetBookmarkByID retrieves a bookmark by ID. +func (q *BookmarkQueries) GetBookmarkByID(ctx context.Context, id uint) (*domain.Bookmark, error) { + if id == 0 { + return nil, errors.New("invalid bookmark ID") + } + return q.repo.GetByID(ctx, id) +} diff --git a/internal/app/category/queries.go b/internal/app/category/queries.go new file mode 100644 index 0000000..87e86d0 --- /dev/null +++ b/internal/app/category/queries.go @@ -0,0 +1,32 @@ +package category + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// CategoryQueries contains the query handlers for the category aggregate. +type CategoryQueries struct { + repo domain.CategoryRepository +} + +// NewCategoryQueries creates a new CategoryQueries handler. +func NewCategoryQueries(repo domain.CategoryRepository) *CategoryQueries { + return &CategoryQueries{ + repo: repo, + } +} + +// GetCategoryByID retrieves a category by ID. +func (q *CategoryQueries) GetCategoryByID(ctx context.Context, id uint) (*domain.Category, error) { + if id == 0 { + return nil, errors.New("invalid category ID") + } + return q.repo.GetByID(ctx, id) +} + +// ListCategories returns a paginated list of categories. +func (q *CategoryQueries) ListCategories(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Category], error) { + return q.repo.List(ctx, page, pageSize) +} diff --git a/internal/app/collection/commands.go b/internal/app/collection/commands.go new file mode 100644 index 0000000..c128a07 --- /dev/null +++ b/internal/app/collection/commands.go @@ -0,0 +1,187 @@ +package collection + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// CollectionCommands contains the command handlers for the collection aggregate. +type CollectionCommands struct { + repo domain.CollectionRepository +} + +// NewCollectionCommands creates a new CollectionCommands handler. +func NewCollectionCommands(repo domain.CollectionRepository) *CollectionCommands { + return &CollectionCommands{ + repo: repo, + } +} + +// CreateCollectionInput represents the input for creating a new collection. +type CreateCollectionInput struct { + Name string + Description string + UserID uint +} + +// CreateCollection creates a new collection. +func (c *CollectionCommands) CreateCollection(ctx context.Context, input CreateCollectionInput) (*domain.Collection, error) { + if input.Name == "" { + return nil, errors.New("collection name cannot be empty") + } + if input.UserID == 0 { + return nil, errors.New("user ID cannot be zero") + } + + collection := &domain.Collection{ + Name: input.Name, + Description: input.Description, + UserID: input.UserID, + } + + err := c.repo.Create(ctx, collection) + if err != nil { + return nil, err + } + + return collection, nil +} + +// UpdateCollectionInput represents the input for updating an existing collection. +type UpdateCollectionInput struct { + ID uint + Name string + Description string + UserID uint // for authorization +} + +// UpdateCollection updates an existing collection. +func (c *CollectionCommands) UpdateCollection(ctx context.Context, input UpdateCollectionInput) (*domain.Collection, error) { + if input.ID == 0 { + return nil, errors.New("collection ID cannot be zero") + } + if input.Name == "" { + return nil, errors.New("collection name cannot be empty") + } + + // Fetch the existing collection + collection, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + if collection == nil { + return nil, errors.New("collection not found") + } + + // Check ownership + if collection.UserID != input.UserID { + return nil, errors.New("unauthorized") + } + + // Update fields + collection.Name = input.Name + collection.Description = input.Description + + err = c.repo.Update(ctx, collection) + if err != nil { + return nil, err + } + + return collection, nil +} + +// DeleteCollectionInput represents the input for deleting a collection. +type DeleteCollectionInput struct { + ID uint + UserID uint // for authorization +} + +// DeleteCollection deletes a collection by ID. +func (c *CollectionCommands) DeleteCollection(ctx context.Context, input DeleteCollectionInput) error { + if input.ID == 0 { + return errors.New("invalid collection ID") + } + + // Fetch the existing collection + collection, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return err + } + if collection == nil { + return errors.New("collection not found") + } + + // Check ownership + if collection.UserID != input.UserID { + return errors.New("unauthorized") + } + + return c.repo.Delete(ctx, input.ID) +} + +// AddWorkToCollectionInput represents the input for adding a work to a collection. +type AddWorkToCollectionInput struct { + CollectionID uint + WorkID uint + UserID uint // for authorization +} + +// AddWorkToCollection adds a work to a collection. +func (c *CollectionCommands) AddWorkToCollection(ctx context.Context, input AddWorkToCollectionInput) error { + if input.CollectionID == 0 { + return errors.New("invalid collection ID") + } + if input.WorkID == 0 { + return errors.New("invalid work ID") + } + + // Fetch the existing collection + collection, err := c.repo.GetByID(ctx, input.CollectionID) + if err != nil { + return err + } + if collection == nil { + return errors.New("collection not found") + } + + // Check ownership + if collection.UserID != input.UserID { + return errors.New("unauthorized") + } + + return c.repo.AddWorkToCollection(ctx, input.CollectionID, input.WorkID) +} + +// RemoveWorkFromCollectionInput represents the input for removing a work from a collection. +type RemoveWorkFromCollectionInput struct { + CollectionID uint + WorkID uint + UserID uint // for authorization +} + +// RemoveWorkFromCollection removes a work from a collection. +func (c *CollectionCommands) RemoveWorkFromCollection(ctx context.Context, input RemoveWorkFromCollectionInput) error { + if input.CollectionID == 0 { + return errors.New("invalid collection ID") + } + if input.WorkID == 0 { + return errors.New("invalid work ID") + } + + // Fetch the existing collection + collection, err := c.repo.GetByID(ctx, input.CollectionID) + if err != nil { + return err + } + if collection == nil { + return errors.New("collection not found") + } + + // Check ownership + if collection.UserID != input.UserID { + return errors.New("unauthorized") + } + + return c.repo.RemoveWorkFromCollection(ctx, input.CollectionID, input.WorkID) +} diff --git a/internal/app/collection/queries.go b/internal/app/collection/queries.go new file mode 100644 index 0000000..bbede46 --- /dev/null +++ b/internal/app/collection/queries.go @@ -0,0 +1,27 @@ +package collection + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// CollectionQueries contains the query handlers for the collection aggregate. +type CollectionQueries struct { + repo domain.CollectionRepository +} + +// NewCollectionQueries creates a new CollectionQueries handler. +func NewCollectionQueries(repo domain.CollectionRepository) *CollectionQueries { + return &CollectionQueries{ + repo: repo, + } +} + +// GetCollectionByID retrieves a collection by ID. +func (q *CollectionQueries) GetCollectionByID(ctx context.Context, id uint) (*domain.Collection, error) { + if id == 0 { + return nil, errors.New("invalid collection ID") + } + return q.repo.GetByID(ctx, id) +} diff --git a/internal/app/comment/commands.go b/internal/app/comment/commands.go new file mode 100644 index 0000000..d648880 --- /dev/null +++ b/internal/app/comment/commands.go @@ -0,0 +1,139 @@ +package comment + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// CommentCommands contains the command handlers for the comment aggregate. +type CommentCommands struct { + repo domain.CommentRepository + analyticsService AnalyticsService +} + +// AnalyticsService defines the interface for analytics operations. +type AnalyticsService interface { + IncrementWorkComments(ctx context.Context, workID uint) error + IncrementTranslationComments(ctx context.Context, translationID uint) error +} + +// NewCommentCommands creates a new CommentCommands handler. +func NewCommentCommands(repo domain.CommentRepository, analyticsService AnalyticsService) *CommentCommands { + return &CommentCommands{ + repo: repo, + analyticsService: analyticsService, + } +} + +// CreateCommentInput represents the input for creating a new comment. +type CreateCommentInput struct { + Text string + UserID uint + WorkID *uint + TranslationID *uint + ParentID *uint +} + +// CreateComment creates a new comment. +func (c *CommentCommands) CreateComment(ctx context.Context, input CreateCommentInput) (*domain.Comment, error) { + if input.Text == "" { + return nil, errors.New("comment text cannot be empty") + } + if input.UserID == 0 { + return nil, errors.New("user ID cannot be zero") + } + + comment := &domain.Comment{ + Text: input.Text, + UserID: input.UserID, + WorkID: input.WorkID, + TranslationID: input.TranslationID, + ParentID: input.ParentID, + } + + err := c.repo.Create(ctx, comment) + if err != nil { + return nil, err + } + + // Increment analytics + if comment.WorkID != nil { + c.analyticsService.IncrementWorkComments(ctx, *comment.WorkID) + } + if comment.TranslationID != nil { + c.analyticsService.IncrementTranslationComments(ctx, *comment.TranslationID) + } + + return comment, nil +} + +// UpdateCommentInput represents the input for updating an existing comment. +type UpdateCommentInput struct { + ID uint + Text string + UserID uint // for authorization +} + +// UpdateComment updates an existing comment. +func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) { + if input.ID == 0 { + return nil, errors.New("comment ID cannot be zero") + } + if input.Text == "" { + return nil, errors.New("comment text cannot be empty") + } + + // Fetch the existing comment + comment, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + if comment == nil { + return nil, errors.New("comment not found") + } + + // Check ownership + if comment.UserID != input.UserID { + return nil, errors.New("unauthorized") + } + + // Update fields + comment.Text = input.Text + + err = c.repo.Update(ctx, comment) + if err != nil { + return nil, err + } + + return comment, nil +} + +// DeleteCommentInput represents the input for deleting a comment. +type DeleteCommentInput struct { + ID uint + UserID uint // for authorization +} + +// DeleteComment deletes a comment by ID. +func (c *CommentCommands) DeleteComment(ctx context.Context, input DeleteCommentInput) error { + if input.ID == 0 { + return errors.New("invalid comment ID") + } + + // Fetch the existing comment + comment, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return err + } + if comment == nil { + return errors.New("comment not found") + } + + // Check ownership + if comment.UserID != input.UserID { + return errors.New("unauthorized") + } + + return c.repo.Delete(ctx, input.ID) +} diff --git a/internal/app/comment/queries.go b/internal/app/comment/queries.go new file mode 100644 index 0000000..45ec53a --- /dev/null +++ b/internal/app/comment/queries.go @@ -0,0 +1,27 @@ +package comment + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// CommentQueries contains the query handlers for the comment aggregate. +type CommentQueries struct { + repo domain.CommentRepository +} + +// NewCommentQueries creates a new CommentQueries handler. +func NewCommentQueries(repo domain.CommentRepository) *CommentQueries { + return &CommentQueries{ + repo: repo, + } +} + +// GetCommentByID retrieves a comment by ID. +func (q *CommentQueries) GetCommentByID(ctx context.Context, id uint) (*domain.Comment, error) { + if id == 0 { + return nil, errors.New("invalid comment ID") + } + return q.repo.GetByID(ctx, id) +} diff --git a/internal/app/like/commands.go b/internal/app/like/commands.go new file mode 100644 index 0000000..780e5c3 --- /dev/null +++ b/internal/app/like/commands.go @@ -0,0 +1,93 @@ +package like + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// LikeCommands contains the command handlers for the like aggregate. +type LikeCommands struct { + repo domain.LikeRepository + analyticsService AnalyticsService +} + +// AnalyticsService defines the interface for analytics operations. +type AnalyticsService interface { + IncrementWorkLikes(ctx context.Context, workID uint) error + IncrementTranslationLikes(ctx context.Context, translationID uint) error +} + +// NewLikeCommands creates a new LikeCommands handler. +func NewLikeCommands(repo domain.LikeRepository, analyticsService AnalyticsService) *LikeCommands { + return &LikeCommands{ + repo: repo, + analyticsService: analyticsService, + } +} + +// CreateLikeInput represents the input for creating a new like. +type CreateLikeInput struct { + UserID uint + WorkID *uint + TranslationID *uint + CommentID *uint +} + +// CreateLike creates a new like. +func (c *LikeCommands) CreateLike(ctx context.Context, input CreateLikeInput) (*domain.Like, error) { + if input.UserID == 0 { + return nil, errors.New("user ID cannot be zero") + } + + like := &domain.Like{ + UserID: input.UserID, + WorkID: input.WorkID, + TranslationID: input.TranslationID, + CommentID: input.CommentID, + } + + err := c.repo.Create(ctx, like) + if err != nil { + return nil, err + } + + // Increment analytics + if like.WorkID != nil { + c.analyticsService.IncrementWorkLikes(ctx, *like.WorkID) + } + if like.TranslationID != nil { + c.analyticsService.IncrementTranslationLikes(ctx, *like.TranslationID) + } + + return like, nil +} + +// DeleteLikeInput represents the input for deleting a like. +type DeleteLikeInput struct { + ID uint + UserID uint // for authorization +} + +// DeleteLike deletes a like by ID. +func (c *LikeCommands) DeleteLike(ctx context.Context, input DeleteLikeInput) error { + if input.ID == 0 { + return errors.New("invalid like ID") + } + + // Fetch the existing like + like, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return err + } + if like == nil { + return errors.New("like not found") + } + + // Check ownership + if like.UserID != input.UserID { + return errors.New("unauthorized") + } + + return c.repo.Delete(ctx, input.ID) +} diff --git a/internal/app/like/queries.go b/internal/app/like/queries.go new file mode 100644 index 0000000..2876dde --- /dev/null +++ b/internal/app/like/queries.go @@ -0,0 +1,27 @@ +package like + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// LikeQueries contains the query handlers for the like aggregate. +type LikeQueries struct { + repo domain.LikeRepository +} + +// NewLikeQueries creates a new LikeQueries handler. +func NewLikeQueries(repo domain.LikeRepository) *LikeQueries { + return &LikeQueries{ + repo: repo, + } +} + +// GetLikeByID retrieves a like by ID. +func (q *LikeQueries) GetLikeByID(ctx context.Context, id uint) (*domain.Like, error) { + if id == 0 { + return nil, errors.New("invalid like ID") + } + return q.repo.GetByID(ctx, id) +} diff --git a/internal/app/localization/service_test.go b/internal/app/localization/service_test.go index 73e1501..1ef060d 100644 --- a/internal/app/localization/service_test.go +++ b/internal/app/localization/service_test.go @@ -97,6 +97,18 @@ func (m *mockTranslationRepository) WithTx(ctx context.Context, fn func(tx *gorm return nil } +func (m *mockTranslationRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.Translation, error) { + var result []domain.Translation + for _, id := range ids { + for _, t := range m.translations { + if t.ID == id { + result = append(result, t) + } + } + } + return result, nil +} + type LocalizationServiceSuite struct { suite.Suite repo *mockTranslationRepository diff --git a/internal/app/tag/queries.go b/internal/app/tag/queries.go new file mode 100644 index 0000000..46fa0ec --- /dev/null +++ b/internal/app/tag/queries.go @@ -0,0 +1,32 @@ +package tag + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// TagQueries contains the query handlers for the tag aggregate. +type TagQueries struct { + repo domain.TagRepository +} + +// NewTagQueries creates a new TagQueries handler. +func NewTagQueries(repo domain.TagRepository) *TagQueries { + return &TagQueries{ + repo: repo, + } +} + +// GetTagByID retrieves a tag by ID. +func (q *TagQueries) GetTagByID(ctx context.Context, id uint) (*domain.Tag, error) { + if id == 0 { + return nil, errors.New("invalid tag ID") + } + return q.repo.GetByID(ctx, id) +} + +// ListTags returns a paginated list of tags. +func (q *TagQueries) ListTags(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Tag], error) { + return q.repo.List(ctx, page, pageSize) +} diff --git a/internal/app/translation/commands.go b/internal/app/translation/commands.go new file mode 100644 index 0000000..e0272ee --- /dev/null +++ b/internal/app/translation/commands.go @@ -0,0 +1,107 @@ +package translation + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// TranslationCommands contains the command handlers for the translation aggregate. +type TranslationCommands struct { + repo domain.TranslationRepository +} + +// NewTranslationCommands creates a new TranslationCommands handler. +func NewTranslationCommands(repo domain.TranslationRepository) *TranslationCommands { + return &TranslationCommands{ + repo: repo, + } +} + +// CreateTranslationInput represents the input for creating a new translation. +type CreateTranslationInput struct { + Title string + Language string + Content string + WorkID uint + IsOriginalLanguage bool +} + +// CreateTranslation creates a new translation. +func (c *TranslationCommands) CreateTranslation(ctx context.Context, input CreateTranslationInput) (*domain.Translation, error) { + if input.Title == "" { + return nil, errors.New("translation title cannot be empty") + } + if input.Language == "" { + return nil, errors.New("translation language cannot be empty") + } + if input.WorkID == 0 { + return nil, errors.New("work ID cannot be zero") + } + + translation := &domain.Translation{ + Title: input.Title, + Language: input.Language, + Content: input.Content, + TranslatableID: input.WorkID, + TranslatableType: "Work", + IsOriginalLanguage: input.IsOriginalLanguage, + } + + err := c.repo.Create(ctx, translation) + if err != nil { + return nil, err + } + + return translation, nil +} + +// UpdateTranslationInput represents the input for updating an existing translation. +type UpdateTranslationInput struct { + ID uint + Title string + Language string + Content string +} + +// UpdateTranslation updates an existing translation. +func (c *TranslationCommands) UpdateTranslation(ctx context.Context, input UpdateTranslationInput) (*domain.Translation, error) { + if input.ID == 0 { + return nil, errors.New("translation ID cannot be zero") + } + if input.Title == "" { + return nil, errors.New("translation title cannot be empty") + } + if input.Language == "" { + return nil, errors.New("translation language cannot be empty") + } + + // Fetch the existing translation + translation, err := c.repo.GetByID(ctx, input.ID) + if err != nil { + return nil, err + } + if translation == nil { + return nil, errors.New("translation not found") + } + + // Update fields + translation.Title = input.Title + translation.Language = input.Language + translation.Content = input.Content + + err = c.repo.Update(ctx, translation) + if err != nil { + return nil, err + } + + return translation, nil +} + +// DeleteTranslation deletes a translation by ID. +func (c *TranslationCommands) DeleteTranslation(ctx context.Context, id uint) error { + if id == 0 { + return errors.New("invalid translation ID") + } + return c.repo.Delete(ctx, id) +} diff --git a/internal/app/translation/queries.go b/internal/app/translation/queries.go new file mode 100644 index 0000000..083fa75 --- /dev/null +++ b/internal/app/translation/queries.go @@ -0,0 +1,27 @@ +package translation + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// TranslationQueries contains the query handlers for the translation aggregate. +type TranslationQueries struct { + repo domain.TranslationRepository +} + +// NewTranslationQueries creates a new TranslationQueries handler. +func NewTranslationQueries(repo domain.TranslationRepository) *TranslationQueries { + return &TranslationQueries{ + repo: repo, + } +} + +// GetTranslationByID retrieves a translation by ID. +func (q *TranslationQueries) GetTranslationByID(ctx context.Context, id uint) (*domain.Translation, error) { + if id == 0 { + return nil, errors.New("invalid translation ID") + } + return q.repo.GetByID(ctx, id) +} diff --git a/internal/app/user/queries.go b/internal/app/user/queries.go new file mode 100644 index 0000000..6036c02 --- /dev/null +++ b/internal/app/user/queries.go @@ -0,0 +1,37 @@ +package user + +import ( + "context" + "errors" + "tercul/internal/domain" +) + +// UserQueries contains the query handlers for the user aggregate. +type UserQueries struct { + repo domain.UserRepository +} + +// NewUserQueries creates a new UserQueries handler. +func NewUserQueries(repo domain.UserRepository) *UserQueries { + return &UserQueries{ + repo: repo, + } +} + +// GetUserByID retrieves a user by ID. +func (q *UserQueries) GetUserByID(ctx context.Context, id uint) (*domain.User, error) { + if id == 0 { + return nil, errors.New("invalid user ID") + } + return q.repo.GetByID(ctx, id) +} + +// ListUsers returns a paginated list of users. +func (q *UserQueries) ListUsers(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.User], error) { + return q.repo.List(ctx, page, pageSize) +} + +// ListUsersByRole returns a list of users by role. +func (q *UserQueries) ListUsersByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) { + return q.repo.ListByRole(ctx, role) +} diff --git a/internal/app/work/main_test.go b/internal/app/work/main_test.go index a28735c..f9b9b6e 100644 --- a/internal/app/work/main_test.go +++ b/internal/app/work/main_test.go @@ -11,6 +11,7 @@ type mockWorkRepository struct { updateFunc func(ctx context.Context, work *domain.Work) error deleteFunc func(ctx context.Context, id uint) error getByIDFunc func(ctx context.Context, id uint) (*domain.Work, error) + getByIDWithOptionsFunc func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) listFunc func(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) getWithTranslationsFunc func(ctx context.Context, id uint) (*domain.Work, error) findByTitleFunc func(ctx context.Context, title string) ([]domain.Work, error) @@ -43,6 +44,13 @@ func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work } return nil, nil } + +func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + if m.getByIDWithOptionsFunc != nil { + return m.getByIDWithOptionsFunc(ctx, id, options) + } + return nil, nil +} func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { if m.listFunc != nil { return m.listFunc(ctx, page, pageSize) diff --git a/internal/app/work/queries.go b/internal/app/work/queries.go index b8f64ff..75432a7 100644 --- a/internal/app/work/queries.go +++ b/internal/app/work/queries.go @@ -45,7 +45,17 @@ func (q *WorkQueries) GetWorkByID(ctx context.Context, id uint) (*domain.Work, e if id == 0 { return nil, errors.New("invalid work ID") } - return q.repo.GetByID(ctx, id) + work, err := q.repo.GetByIDWithOptions(ctx, id, &domain.QueryOptions{Preloads: []string{"Authors"}}) + if err != nil { + return nil, err + } + if work != nil { + work.AuthorIDs = make([]uint, len(work.Authors)) + for i, author := range work.Authors { + work.AuthorIDs[i] = author.ID + } + } + return work, nil } // ListWorks returns a paginated list of works. diff --git a/internal/app/work/queries_test.go b/internal/app/work/queries_test.go index 3a4d585..a5a1b4e 100644 --- a/internal/app/work/queries_test.go +++ b/internal/app/work/queries_test.go @@ -26,12 +26,16 @@ func TestWorkQueriesSuite(t *testing.T) { func (s *WorkQueriesSuite) TestGetWorkByID_Success() { work := &domain.Work{Title: "Test Work"} work.ID = 1 - s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.Work, error) { + work.Authors = []*domain.Author{ + {TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: 1}}, Name: "Author 1"}, + } + s.repo.getByIDWithOptionsFunc = func(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { return work, nil } w, err := s.queries.GetWorkByID(context.Background(), 1) assert.NoError(s.T(), err) assert.Equal(s.T(), work, w) + assert.Equal(s.T(), []uint{1}, w.AuthorIDs) } func (s *WorkQueriesSuite) TestGetWorkByID_ZeroID() { diff --git a/internal/data/sql/author_repository.go b/internal/data/sql/author_repository.go index b8cf5e1..38bb8c4 100644 --- a/internal/data/sql/author_repository.go +++ b/internal/data/sql/author_repository.go @@ -31,6 +31,15 @@ func (r *authorRepository) ListByWorkID(ctx context.Context, workID uint) ([]dom return authors, nil } +// GetByIDs finds authors by a list of IDs +func (r *authorRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.Author, error) { + var authors []domain.Author + if err := r.db.WithContext(ctx).Where("id IN (?)", ids).Find(&authors).Error; err != nil { + return nil, err + } + return authors, nil +} + // ListByBookID finds authors by book ID func (r *authorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) { var authors []domain.Author diff --git a/internal/data/sql/translation_repository.go b/internal/data/sql/translation_repository.go index 28e332e..8d2e933 100644 --- a/internal/data/sql/translation_repository.go +++ b/internal/data/sql/translation_repository.go @@ -55,3 +55,12 @@ func (r *translationRepository) ListByStatus(ctx context.Context, status domain. } return translations, nil } + +// GetByIDs finds translations by a list of IDs +func (r *translationRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.Translation, error) { + var translations []domain.Translation + if err := r.db.WithContext(ctx).Where("id IN (?)", ids).Find(&translations).Error; err != nil { + return nil, err + } + return translations, nil +} diff --git a/internal/data/sql/user_repository.go b/internal/data/sql/user_repository.go index a409e60..4604327 100644 --- a/internal/data/sql/user_repository.go +++ b/internal/data/sql/user_repository.go @@ -53,3 +53,12 @@ func (r *userRepository) ListByRole(ctx context.Context, role domain.UserRole) ( } return users, nil } + +// GetByIDs finds users by a list of IDs +func (r *userRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.User, error) { + var users []domain.User + if err := r.db.WithContext(ctx).Where("id IN (?)", ids).Find(&users).Error; err != nil { + return nil, err + } + return users, nil +} diff --git a/internal/data/sql/work_repository.go b/internal/data/sql/work_repository.go index effd495..265abd7 100644 --- a/internal/data/sql/work_repository.go +++ b/internal/data/sql/work_repository.go @@ -99,6 +99,15 @@ func (r *workRepository) FindByLanguage(ctx context.Context, language string, pa }, nil } +// GetByIDs finds works by a list of IDs +func (r *workRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.Work, error) { + var works []domain.Work + if err := r.db.WithContext(ctx).Where("id IN (?)", ids).Find(&works).Error; err != nil { + return nil, err + } + return works, nil +} + diff --git a/internal/domain/entities.go b/internal/domain/entities.go index dec2936..5cd8163 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -211,6 +211,7 @@ type Work struct { PublishedAt *time.Time Translations []Translation `gorm:"polymorphic:Translatable"` Authors []*Author `gorm:"many2many:work_authors"` + AuthorIDs []uint `gorm:"-"` Tags []*Tag `gorm:"many2many:work_tags"` Categories []*Category `gorm:"many2many:work_categories"` Copyrights []*Copyright `gorm:"many2many:work_copyrights;constraint:OnDelete:CASCADE"` diff --git a/internal/domain/interfaces.go b/internal/domain/interfaces.go index 9a110f4..5e91b4f 100644 --- a/internal/domain/interfaces.go +++ b/internal/domain/interfaces.go @@ -179,6 +179,7 @@ type TranslationRepository interface { ListByEntity(ctx context.Context, entityType string, entityID uint) ([]Translation, error) ListByTranslatorID(ctx context.Context, translatorID uint) ([]Translation, error) ListByStatus(ctx context.Context, status TranslationStatus) ([]Translation, error) + GetByIDs(ctx context.Context, ids []uint) ([]Translation, error) } // UserRepository defines CRUD methods specific to User. @@ -187,6 +188,7 @@ type UserRepository interface { FindByUsername(ctx context.Context, username string) (*User, error) FindByEmail(ctx context.Context, email string) (*User, error) ListByRole(ctx context.Context, role UserRole) ([]User, error) + GetByIDs(ctx context.Context, ids []uint) ([]User, error) } // UserProfileRepository defines CRUD methods specific to UserProfile. @@ -243,6 +245,7 @@ type WorkRepository interface { FindByLanguage(ctx context.Context, language string, page, pageSize int) (*PaginatedResult[Work], error) GetWithTranslations(ctx context.Context, id uint) (*Work, error) ListWithTranslations(ctx context.Context, page, pageSize int) (*PaginatedResult[Work], error) + GetByIDs(ctx context.Context, ids []uint) ([]Work, error) } // AuthorRepository defines CRUD methods specific to Author. @@ -251,6 +254,7 @@ type AuthorRepository interface { ListByWorkID(ctx context.Context, workID uint) ([]Author, error) ListByBookID(ctx context.Context, bookID uint) ([]Author, error) ListByCountryID(ctx context.Context, countryID uint) ([]Author, error) + GetByIDs(ctx context.Context, ids []uint) ([]Author, error) } diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go index 5dffb98..e39ece5 100644 --- a/internal/testutil/integration_test_utils.go +++ b/internal/testutil/integration_test_utils.go @@ -25,8 +25,902 @@ import ( "tercul/internal/data/sql" "tercul/internal/domain" "tercul/internal/jobs/linguistics" + "github.com/stretchr/testify/mock" ) +type MockWorkRepository struct { + mock.Mock +} + +type MockUserRepository struct { + mock.Mock +} + +type MockAuthorRepository struct { + mock.Mock +} + +type MockCommentRepository struct { + mock.Mock +} + +type MockLikeRepository struct { + mock.Mock +} + +type MockBookmarkRepository struct { + mock.Mock +} + +type MockCollectionRepository struct { + mock.Mock +} + +type MockTagRepository struct { + mock.Mock +} + +type MockCategoryRepository struct { + mock.Mock +} + +func (m *MockLikeRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockBookmarkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockCollectionRepository) AddWorkToCollection(ctx context.Context, collectionID uint, workID uint) error { + args := m.Called(ctx, collectionID, workID) + return args.Error(0) +} + +func (m *MockTagRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockCategoryRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockLikeRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockLikeRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockLikeRepository) Create(ctx context.Context, like *domain.Like) error { + args := m.Called(ctx, like) + return args.Error(0) +} + +func (m *MockBookmarkRepository) Create(ctx context.Context, bookmark *domain.Bookmark) error { + args := m.Called(ctx, bookmark) + return args.Error(0) +} + +func (m *MockCollectionRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockTagRepository) Create(ctx context.Context, tag *domain.Tag) error { + args := m.Called(ctx, tag) + return args.Error(0) +} + +func (m *MockCategoryRepository) Create(ctx context.Context, category *domain.Category) error { + args := m.Called(ctx, category) + return args.Error(0) +} + +func (m *MockLikeRepository) Update(ctx context.Context, like *domain.Like) error { + args := m.Called(ctx, like) + return args.Error(0) +} + +func (m *MockBookmarkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockCollectionRepository) RemoveWorkFromCollection(ctx context.Context, collectionID uint, workID uint) error { + args := m.Called(ctx, collectionID, workID) + return args.Error(0) +} + +func (m *MockTagRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockCategoryRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Category) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCollectionRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockLikeRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCollectionRepository) Update(ctx context.Context, collection *domain.Collection) error { + args := m.Called(ctx, collection) + return args.Error(0) +} + +func (m *MockCategoryRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockLikeRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockCollectionRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Collection) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockLikeRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Like) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +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 { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +func (m *MockLikeRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Like, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Bookmark, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockTagRepository) FindByName(ctx context.Context, name string) (*domain.Tag, error) { + args := m.Called(ctx, name) + return args.Get(0).(*domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) FindByName(ctx context.Context, name string) (*domain.Category, error) { + args := m.Called(ctx, name) + return args.Get(0).(*domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Like, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Bookmark, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Collection, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Tag, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Category, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) GetByID(ctx context.Context, id uint) (*domain.Like, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) GetByID(ctx context.Context, id uint) (*domain.Bookmark, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Collection, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Tag, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Category, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Like, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Bookmark, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) GetByID(ctx context.Context, id uint) (*domain.Collection, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) GetByID(ctx context.Context, id uint) (*domain.Tag, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) GetByID(ctx context.Context, id uint) (*domain.Category, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Like], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Like]), args.Error(1) +} + +func (m *MockBookmarkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Bookmark], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Bookmark]), args.Error(1) +} + +func (m *MockCollectionRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Collection, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Tag, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Category, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) ListAll(ctx context.Context) ([]domain.Bookmark, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Collection], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Collection]), args.Error(1) +} + +func (m *MockTagRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Tag], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Tag]), args.Error(1) +} + +func (m *MockCategoryRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Category], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Category]), args.Error(1) +} + +func (m *MockLikeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { + args := m.Called(ctx, commentID) + return args.Get(0).([]domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Bookmark, error) { + args := m.Called(ctx, userID) + return args.Get(0).([]domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) ListAll(ctx context.Context) ([]domain.Collection, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) ListAll(ctx context.Context) ([]domain.Tag, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) ListAll(ctx context.Context) ([]domain.Category, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { + args := m.Called(ctx, translationID) + return args.Get(0).([]domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Bookmark, error) { + args := m.Called(ctx, workID) + return args.Get(0).([]domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Collection, error) { + args := m.Called(ctx, userID) + return args.Get(0).([]domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Tag, error) { + args := m.Called(ctx, workID) + return args.Get(0).([]domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) ListByParentID(ctx context.Context, parentID *uint) ([]domain.Category, error) { + args := m.Called(ctx, parentID) + return args.Get(0).([]domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { + args := m.Called(ctx, userID) + return args.Get(0).([]domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Bookmark, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Bookmark), args.Error(1) +} + +func (m *MockCollectionRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Collection, error) { + args := m.Called(ctx, workID) + return args.Get(0).([]domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Tag, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Tag), args.Error(1) +} + +func (m *MockCategoryRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Category, error) { + args := m.Called(ctx, workID) + return args.Get(0).([]domain.Category), args.Error(1) +} + +func (m *MockLikeRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Like, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Bookmark) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCollectionRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Collection, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Tag) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCategoryRepository) Update(ctx context.Context, category *domain.Category) error { + args := m.Called(ctx, category) + return args.Error(0) +} + +func (m *MockLikeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { + args := m.Called(ctx, workID) + return args.Get(0).([]domain.Like), args.Error(1) +} + +func (m *MockBookmarkRepository) Update(ctx context.Context, bookmark *domain.Bookmark) error { + args := m.Called(ctx, bookmark) + return args.Error(0) +} + +func (m *MockCollectionRepository) ListPublic(ctx context.Context) ([]domain.Collection, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Collection), args.Error(1) +} + +func (m *MockTagRepository) Update(ctx context.Context, tag *domain.Tag) error { + args := m.Called(ctx, tag) + return args.Error(0) +} + +func (m *MockCategoryRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Category, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Category), args.Error(1) +} + +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 *MockBookmarkRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockCollectionRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +func (m *MockTagRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockCategoryRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockBookmarkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +func (m *MockCollectionRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockTagRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +func (m *MockCategoryRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +func (m *MockBookmarkRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockCollectionRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Collection) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockTagRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockCategoryRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockBookmarkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Bookmark) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCollectionRepository) Create(ctx context.Context, collection *domain.Collection) error { + args := m.Called(ctx, collection) + return args.Error(0) +} + +func (m *MockTagRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Tag) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCategoryRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Category) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockBookmarkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockCollectionRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockTagRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockCategoryRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockBookmarkRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockCollectionRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockTagRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockCategoryRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) Reset() { + m.Mock = mock.Mock{} +} + +func (m *UnifiedMockWorkRepository) AddWork(work *domain.Work) { + // Not implemented for mock +} + +func (m *MockCommentRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockCommentRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockCommentRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockCommentRepository) Create(ctx context.Context, comment *domain.Comment) error { + args := m.Called(ctx, comment) + return args.Error(0) +} + +func (m *MockCommentRepository) GetByID(ctx context.Context, id uint) (*domain.Comment, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) Update(ctx context.Context, comment *domain.Comment) error { + args := m.Called(ctx, comment) + return args.Error(0) +} + +func (m *MockCommentRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockCommentRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Comment], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Comment]), args.Error(1) +} + +func (m *MockCommentRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Comment, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) ListAll(ctx context.Context) ([]domain.Comment, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Comment, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Comment, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockCommentRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockCommentRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Comment, error) { + args := m.Called(ctx, userID) + return args.Get(0).([]domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Comment, error) { + args := m.Called(ctx, workID) + return args.Get(0).([]domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Comment, error) { + args := m.Called(ctx, translationID) + return args.Get(0).([]domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) ListByParentID(ctx context.Context, parentID uint) ([]domain.Comment, error) { + args := m.Called(ctx, parentID) + return args.Get(0).([]domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Comment, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Comment), args.Error(1) +} + +func (m *MockCommentRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Comment) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCommentRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Comment) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockCommentRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +func (m *MockAuthorRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockAuthorRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockAuthorRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockAuthorRepository) Create(ctx context.Context, author *domain.Author) error { + args := m.Called(ctx, author) + return args.Error(0) +} + +func (m *MockAuthorRepository) GetByID(ctx context.Context, id uint) (*domain.Author, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) Update(ctx context.Context, author *domain.Author) error { + args := m.Called(ctx, author) + return args.Error(0) +} + +func (m *MockAuthorRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockAuthorRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Author], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Author]), args.Error(1) +} + +func (m *MockAuthorRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Author, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) ListAll(ctx context.Context) ([]domain.Author, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Author, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Author, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockAuthorRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockAuthorRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Author, error) { + args := m.Called(ctx, workID) + return args.Get(0).([]domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) ListByBookID(ctx context.Context, bookID uint) ([]domain.Author, error) { + args := m.Called(ctx, bookID) + return args.Get(0).([]domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) ListByCountryID(ctx context.Context, countryID uint) ([]domain.Author, error) { + args := m.Called(ctx, countryID) + return args.Get(0).([]domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.Author, error) { + args := m.Called(ctx, ids) + return args.Get(0).([]domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Author, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Author), args.Error(1) +} + +func (m *MockAuthorRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Author) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockAuthorRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Author) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockAuthorRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +func (m *MockUserRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *MockUserRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockUserRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { + args := m.Called(ctx, user) + return args.Error(0) +} + +func (m *MockUserRepository) GetByID(ctx context.Context, id uint) (*domain.User, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.User), args.Error(1) +} + +func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { + args := m.Called(ctx, user) + return args.Error(0) +} + +func (m *MockUserRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *MockUserRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.User], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.User]), args.Error(1) +} + +func (m *MockUserRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.User, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.User), args.Error(1) +} + +func (m *MockUserRepository) ListAll(ctx context.Context) ([]domain.User, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.User), args.Error(1) +} + +func (m *MockUserRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.User, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.User), args.Error(1) +} + +func (m *MockUserRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.User, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.User), args.Error(1) +} + +func (m *MockUserRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *MockUserRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *MockUserRepository) FindByUsername(ctx context.Context, username string) (*domain.User, error) { + args := m.Called(ctx, username) + return args.Get(0).(*domain.User), args.Error(1) +} + +func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { + args := m.Called(ctx, email) + return args.Get(0).(*domain.User), args.Error(1) +} + +func (m *MockUserRepository) ListByRole(ctx context.Context, role domain.UserRole) ([]domain.User, error) { + args := m.Called(ctx, role) + return args.Get(0).([]domain.User), args.Error(1) +} + +func (m *MockUserRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.User, error) { + args := m.Called(ctx, ids) + return args.Get(0).([]domain.User), args.Error(1) +} + +func (m *MockUserRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.User, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.User), args.Error(1) +} + +func (m *MockUserRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockUserRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.User) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *MockUserRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} + +type UnifiedMockWorkRepository struct { + mock.Mock + MockWorkRepository +} + // IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories type IntegrationTestSuite struct { suite.Suite @@ -209,16 +1103,52 @@ func (s *IntegrationTestSuite) setupInMemoryDB(config *TestConfig) { // setupMockRepositories sets up mock repositories for testing func (s *IntegrationTestSuite) setupMockRepositories() { s.WorkRepo = NewUnifiedMockWorkRepository() - // Temporarily comment out problematic repositories until we fix the interface implementations - // s.UserRepo = NewMockUserRepository() - // s.AuthorRepo = NewMockAuthorRepository() - // s.TranslationRepo = NewMockTranslationRepository() - // s.CommentRepo = NewMockCommentRepository() - // s.LikeRepo = NewMockLikeRepository() - // s.BookmarkRepo = NewMockBookmarkRepository() - // s.CollectionRepo = NewMockCollectionRepository() - // s.TagRepo = NewMockTagRepository() - // s.CategoryRepo = NewMockCategoryRepository() + s.UserRepo = NewMockUserRepository() + s.AuthorRepo = NewMockAuthorRepository() + s.TranslationRepo = NewMockTranslationRepository() + s.CommentRepo = NewMockCommentRepository() + s.LikeRepo = NewMockLikeRepository() + s.BookmarkRepo = NewMockBookmarkRepository() + s.CollectionRepo = NewMockCollectionRepository() + s.TagRepo = NewMockTagRepository() + s.CategoryRepo = NewMockCategoryRepository() +} + +// Mock repository constructors +func NewMockUserRepository() *MockUserRepository { + return &MockUserRepository{} +} + +func NewMockAuthorRepository() *MockAuthorRepository { + return &MockAuthorRepository{} +} + +func NewMockCommentRepository() *MockCommentRepository { + return &MockCommentRepository{} +} + +func NewMockLikeRepository() *MockLikeRepository { + return &MockLikeRepository{} +} + +func NewMockBookmarkRepository() *MockBookmarkRepository { + return &MockBookmarkRepository{} +} + +func NewMockCollectionRepository() *MockCollectionRepository { + return &MockCollectionRepository{} +} + +func NewMockTagRepository() *MockTagRepository { + return &MockTagRepository{} +} + +func NewMockCategoryRepository() *MockCategoryRepository { + return &MockCategoryRepository{} +} + +func NewUnifiedMockWorkRepository() *UnifiedMockWorkRepository { + return &UnifiedMockWorkRepository{} } // setupServices sets up service instances @@ -251,20 +1181,6 @@ func (s *IntegrationTestSuite) setupServices() { Search: search.NewIndexService(s.Localization, &MockWeaviateWrapper{}), MonetizationCommands: monetizationCommands, MonetizationQueries: monetizationQueries, - AuthorRepo: s.AuthorRepo, - UserRepo: s.UserRepo, - TagRepo: s.TagRepo, - CategoryRepo: s.CategoryRepo, - BookRepo: s.BookRepo, - PublisherRepo: s.PublisherRepo, - SourceRepo: s.SourceRepo, - TranslationRepo: s.TranslationRepo, - CopyrightRepo: s.CopyrightRepo, - MonetizationRepo: s.MonetizationRepo, - CommentRepo: s.CommentRepo, - LikeRepo: s.LikeRepo, - BookmarkRepo: s.BookmarkRepo, - CollectionRepo: s.CollectionRepo, } } @@ -449,3 +1365,126 @@ func (s *IntegrationTestSuite) CreateTestTranslation(workID uint, language, cont s.Require().NoError(err) return translation } + +func (m *UnifiedMockWorkRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.Work, error) { + args := m.Called(ctx, ids) + return args.Get(0).([]domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, nil +} + +func (m *UnifiedMockWorkRepository) Count(ctx context.Context) (int64, error) { + args := m.Called(ctx) + return args.Get(0).(int64), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { + args := m.Called(ctx, options) + return args.Get(0).(int64), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) Create(ctx context.Context, work *domain.Work) error { + args := m.Called(ctx, work) + return args.Error(0) +} + +func (m *UnifiedMockWorkRepository) GetByID(ctx context.Context, id uint) (*domain.Work, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) Update(ctx context.Context, work *domain.Work) error { + args := m.Called(ctx, work) + return args.Error(0) +} + +func (m *UnifiedMockWorkRepository) Delete(ctx context.Context, id uint) error { + args := m.Called(ctx, id) + return args.Error(0) +} + +func (m *UnifiedMockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Work]), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Work, error) { + args := m.Called(ctx, options) + return args.Get(0).([]domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) ListAll(ctx context.Context) ([]domain.Work, error) { + args := m.Called(ctx) + return args.Get(0).([]domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Work, error) { + args := m.Called(ctx, preloads, id) + return args.Get(0).(*domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Work, error) { + args := m.Called(ctx, batchSize, offset) + return args.Get(0).([]domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { + args := m.Called(ctx, id) + return args.Bool(0), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fn(nil) +} + +func (m *UnifiedMockWorkRepository) FindByTitle(ctx context.Context, title string) ([]domain.Work, error) { + args := m.Called(ctx, title) + return args.Get(0).([]domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]domain.Work, error) { + args := m.Called(ctx, authorID) + return args.Get(0).([]domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]domain.Work, error) { + args := m.Called(ctx, categoryID) + return args.Get(0).([]domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + args := m.Called(ctx, language, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Work]), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*domain.Work, error) { + args := m.Called(ctx, id) + return args.Get(0).(*domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Work], error) { + args := m.Called(ctx, page, pageSize) + return args.Get(0).(*domain.PaginatedResult[domain.Work]), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*domain.Work, error) { + args := m.Called(ctx, id, options) + return args.Get(0).(*domain.Work), args.Error(1) +} + +func (m *UnifiedMockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *UnifiedMockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *domain.Work) error { + args := m.Called(ctx, tx, entity) + return args.Error(0) +} + +func (m *UnifiedMockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + args := m.Called(ctx, tx, id) + return args.Error(0) +} diff --git a/internal/testutil/mock_translation_repository.go b/internal/testutil/mock_translation_repository.go index de51b7c..8772f38 100644 --- a/internal/testutil/mock_translation_repository.go +++ b/internal/testutil/mock_translation_repository.go @@ -187,3 +187,15 @@ func (m *MockTranslationRepository) AddTranslationForWork(workID uint, language IsOriginalLanguage: isOriginal, }) } + +func (m *MockTranslationRepository) GetByIDs(ctx context.Context, ids []uint) ([]domain.Translation, error) { + var results []domain.Translation + for _, id := range ids { + for _, item := range m.items { + if item.ID == id { + results = append(results, item) + } + } + } + return results, nil +} diff --git a/internal/testutil/mock_work_repository.go b/internal/testutil/mock_work_repository.go deleted file mode 100644 index 4b611bc..0000000 --- a/internal/testutil/mock_work_repository.go +++ /dev/null @@ -1,255 +0,0 @@ -package testutil - -import ( - "context" - "gorm.io/gorm" - "tercul/internal/domain" -) - -// UnifiedMockWorkRepository is a shared mock for WorkRepository tests -// Implements all required methods and uses an in-memory slice - -type UnifiedMockWorkRepository struct { - Works []*domain.Work -} - -func NewUnifiedMockWorkRepository() *UnifiedMockWorkRepository { - return &UnifiedMockWorkRepository{Works: []*domain.Work{}} -} - -func (m *UnifiedMockWorkRepository) AddWork(work *domain.Work) { - 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) { - for _, w := range m.Works { - if w.ID == id { - return w, nil - } - } - return nil, ErrEntityNotFound -} - -func (m *UnifiedMockWorkRepository) 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 -} - -func (m *UnifiedMockWorkRepository) 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 -} - -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 { - 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 - } - return result.Items, nil -} - -func (m *UnifiedMockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - return m.Count(ctx) -} - -func (m *UnifiedMockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { - _, err := m.GetByID(ctx, id) - return err == nil, nil -} - -func (m *UnifiedMockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { - return nil, nil -} - -func (m *UnifiedMockWorkRepository) 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