mirror of
https://github.com/SamyRai/tercul-backend.git
synced 2025-12-27 05:11:34 +00:00
feat: Implement production-ready API patterns
This commit introduces a comprehensive set of foundational improvements to make the API more robust, secure, and observable. The following features have been implemented: - **Observability Stack:** A new `internal/observability` package has been added, providing structured logging with `zerolog`, Prometheus metrics, and OpenTelemetry tracing. This stack is fully integrated into the application's request pipeline. - **Centralized Authorization:** A new `internal/app/authz` service has been created to centralize authorization logic. This service is now used by the `user`, `work`, and `comment` services to protect all Create, Update, and Delete operations. - **Standardized Input Validation:** The previous ad-hoc validation has been replaced with a more robust, struct-tag-based system using the `go-playground/validator` library. This has been applied to all GraphQL input models. - **Structured Error Handling:** A new set of custom error types has been introduced in the `internal/domain` package. A custom `gqlgen` error presenter has been implemented to map these domain errors to structured GraphQL error responses with specific error codes. - **`updateUser` Endpoint:** The `updateUser` mutation has been fully implemented as a proof of concept for the new patterns, including support for partial updates and comprehensive authorization checks. - **Test Refactoring:** The test suite has been significantly improved by decoupling mock repositories from the shared `testutil` package, resolving circular dependency issues and making the tests more maintainable.
This commit is contained in:
parent
3bcd8d08f5
commit
9fd2331eb4
@ -26,7 +26,10 @@ func NewServer(resolver *graphql.Resolver) http.Handler {
|
|||||||
func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics) http.Handler {
|
func NewServerWithAuth(resolver *graphql.Resolver, jwtManager *auth.JWTManager, metrics *observability.Metrics) http.Handler {
|
||||||
c := graphql.Config{Resolvers: resolver}
|
c := graphql.Config{Resolvers: resolver}
|
||||||
c.Directives.Binding = graphql.Binding
|
c.Directives.Binding = graphql.Binding
|
||||||
|
|
||||||
|
// Create the server with the custom error presenter
|
||||||
srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c))
|
srv := handler.NewDefaultServer(graphql.NewExecutableSchema(c))
|
||||||
|
srv.SetErrorPresenter(graphql.NewErrorPresenter())
|
||||||
|
|
||||||
// Create a middleware chain
|
// Create a middleware chain
|
||||||
var chain http.Handler
|
var chain http.Handler
|
||||||
|
|||||||
49
internal/adapters/graphql/errors.go
Normal file
49
internal/adapters/graphql/errors.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package graphql
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
|
||||||
|
"github.com/99designs/gqlgen/graphql"
|
||||||
|
"github.com/vektah/gqlparser/v2/gqlerror"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewErrorPresenter creates a custom error presenter for gqlgen.
|
||||||
|
func NewErrorPresenter() graphql.ErrorPresenterFunc {
|
||||||
|
return func(ctx context.Context, e error) *gqlerror.Error {
|
||||||
|
gqlErr := graphql.DefaultErrorPresenter(ctx, e)
|
||||||
|
|
||||||
|
// Unwrap the error to find the root cause.
|
||||||
|
originalErr := errors.Unwrap(e)
|
||||||
|
if originalErr == nil {
|
||||||
|
originalErr = e
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for custom application errors and format them.
|
||||||
|
switch {
|
||||||
|
case errors.Is(originalErr, domain.ErrNotFound):
|
||||||
|
gqlErr.Message = "The requested resource was not found."
|
||||||
|
gqlErr.Extensions = map[string]interface{}{"code": "NOT_FOUND"}
|
||||||
|
case errors.Is(originalErr, domain.ErrUnauthorized):
|
||||||
|
gqlErr.Message = "You must be logged in to perform this action."
|
||||||
|
gqlErr.Extensions = map[string]interface{}{"code": "UNAUTHENTICATED"}
|
||||||
|
case errors.Is(originalErr, domain.ErrForbidden):
|
||||||
|
gqlErr.Message = "You are not authorized to perform this action."
|
||||||
|
gqlErr.Extensions = map[string]interface{}{"code": "FORBIDDEN"}
|
||||||
|
case errors.Is(originalErr, domain.ErrValidation):
|
||||||
|
// Keep the detailed message from the validation error.
|
||||||
|
gqlErr.Message = originalErr.Error()
|
||||||
|
gqlErr.Extensions = map[string]interface{}{"code": "VALIDATION_FAILED"}
|
||||||
|
case errors.Is(originalErr, domain.ErrConflict):
|
||||||
|
gqlErr.Message = "A conflict occurred with the current state of the resource."
|
||||||
|
gqlErr.Extensions = map[string]interface{}{"code": "CONFLICT"}
|
||||||
|
default:
|
||||||
|
// For all other errors, return a generic message to avoid leaking implementation details.
|
||||||
|
gqlErr.Message = "An unexpected internal error occurred."
|
||||||
|
gqlErr.Extensions = map[string]interface{}{"code": "INTERNAL_SERVER_ERROR"}
|
||||||
|
}
|
||||||
|
|
||||||
|
return gqlErr
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -48,14 +48,21 @@ func (s *GraphQLIntegrationSuite) CreateAuthenticatedUser(username, email string
|
|||||||
|
|
||||||
// Update user role if necessary
|
// Update user role if necessary
|
||||||
user := authResponse.User
|
user := authResponse.User
|
||||||
|
token := authResponse.Token
|
||||||
if user.Role != role {
|
if user.Role != role {
|
||||||
// This part is tricky. There is no UpdateUserRole command.
|
// This part is tricky. There is no UpdateUserRole command.
|
||||||
// For a test, I can update the DB directly.
|
// For a test, I can update the DB directly.
|
||||||
s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role)
|
s.DB.Model(&domain.User{}).Where("id = ?", user.ID).Update("role", role)
|
||||||
user.Role = role
|
user.Role = role
|
||||||
|
|
||||||
|
// Re-generate the token with the new role
|
||||||
|
var err error
|
||||||
|
jwtManager := platform_auth.NewJWTManager()
|
||||||
|
token, err = jwtManager.GenerateToken(user)
|
||||||
|
s.Require().NoError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return user, authResponse.Token
|
return user, token
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetupSuite sets up the test suite
|
// SetupSuite sets up the test suite
|
||||||
@ -507,6 +514,7 @@ func (s *GraphQLIntegrationSuite) TestUpdateTranslationValidation() {
|
|||||||
func (s *GraphQLIntegrationSuite) TestDeleteWork() {
|
func (s *GraphQLIntegrationSuite) TestDeleteWork() {
|
||||||
s.Run("should delete a work", func() {
|
s.Run("should delete a work", func() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
_, token := s.CreateAuthenticatedUser("work_deleter", "work_deleter@test.com", domain.UserRoleAdmin)
|
||||||
work := s.CreateTestWork("Test Work", "en", "Test content")
|
work := s.CreateTestWork("Test Work", "en", "Test content")
|
||||||
|
|
||||||
// Define the mutation
|
// Define the mutation
|
||||||
@ -522,7 +530,7 @@ func (s *GraphQLIntegrationSuite) TestDeleteWork() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Execute the mutation
|
// Execute the mutation
|
||||||
response, err := executeGraphQL[any](s, mutation, variables, nil)
|
response, err := executeGraphQL[any](s, mutation, variables, &token)
|
||||||
s.Require().NoError(err)
|
s.Require().NoError(err)
|
||||||
s.Require().NotNil(response)
|
s.Require().NotNil(response)
|
||||||
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
||||||
@ -991,6 +999,109 @@ func (s *GraphQLIntegrationSuite) TestTrendingWorksQuery() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UpdateUserResponse struct {
|
||||||
|
UpdateUser struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
} `json:"updateUser"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *GraphQLIntegrationSuite) TestUpdateUser() {
|
||||||
|
// Create users for testing authorization
|
||||||
|
user1, user1Token := s.CreateAuthenticatedUser("user1", "user1@test.com", domain.UserRoleReader)
|
||||||
|
_, user2Token := s.CreateAuthenticatedUser("user2", "user2@test.com", domain.UserRoleReader)
|
||||||
|
_, adminToken := s.CreateAuthenticatedUser("admin", "admin@test.com", domain.UserRoleAdmin)
|
||||||
|
|
||||||
|
s.Run("a user can update their own profile", func() {
|
||||||
|
// Define the mutation
|
||||||
|
mutation := `
|
||||||
|
mutation UpdateUser($id: ID!, $input: UserInput!) {
|
||||||
|
updateUser(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
email
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Define the variables
|
||||||
|
newUsername := "user1_updated"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": fmt.Sprintf("%d", user1.ID),
|
||||||
|
"input": map[string]interface{}{
|
||||||
|
"username": newUsername,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the mutation
|
||||||
|
response, err := executeGraphQL[UpdateUserResponse](s, mutation, variables, &user1Token)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().NotNil(response)
|
||||||
|
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
||||||
|
|
||||||
|
// Verify the response
|
||||||
|
s.Equal(fmt.Sprintf("%d", user1.ID), response.Data.UpdateUser.ID)
|
||||||
|
s.Equal(newUsername, response.Data.UpdateUser.Username)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Run("a user is forbidden from updating another user's profile", func() {
|
||||||
|
// Define the mutation
|
||||||
|
mutation := `
|
||||||
|
mutation UpdateUser($id: ID!, $input: UserInput!) {
|
||||||
|
updateUser(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Define the variables
|
||||||
|
newUsername := "user2_updated_by_user1"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": fmt.Sprintf("%d", user1.ID), // trying to update user1
|
||||||
|
"input": map[string]interface{}{
|
||||||
|
"username": newUsername,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the mutation with user2's token
|
||||||
|
response, err := executeGraphQL[any](s, mutation, variables, &user2Token)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().NotNil(response.Errors)
|
||||||
|
})
|
||||||
|
|
||||||
|
s.Run("an admin can update any user's profile", func() {
|
||||||
|
// Define the mutation
|
||||||
|
mutation := `
|
||||||
|
mutation UpdateUser($id: ID!, $input: UserInput!) {
|
||||||
|
updateUser(id: $id, input: $input) {
|
||||||
|
id
|
||||||
|
username
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
// Define the variables
|
||||||
|
newUsername := "user1_updated_by_admin"
|
||||||
|
variables := map[string]interface{}{
|
||||||
|
"id": fmt.Sprintf("%d", user1.ID),
|
||||||
|
"input": map[string]interface{}{
|
||||||
|
"username": newUsername,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the mutation with the admin's token
|
||||||
|
response, err := executeGraphQL[UpdateUserResponse](s, mutation, variables, &adminToken)
|
||||||
|
s.Require().NoError(err)
|
||||||
|
s.Require().NotNil(response)
|
||||||
|
s.Require().Nil(response.Errors, "GraphQL mutation should not return errors")
|
||||||
|
|
||||||
|
// Verify the response
|
||||||
|
s.Equal(fmt.Sprintf("%d", user1.ID), response.Data.UpdateUser.ID)
|
||||||
|
s.Equal(newUsername, response.Data.UpdateUser.Username)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GraphQLIntegrationSuite) TestCollectionMutations() {
|
func (s *GraphQLIntegrationSuite) TestCollectionMutations() {
|
||||||
// Create users for testing authorization
|
// Create users for testing authorization
|
||||||
owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader)
|
owner, ownerToken := s.CreateAuthenticatedUser("collectionowner", "owner@test.com", domain.UserRoleReader)
|
||||||
|
|||||||
@ -24,14 +24,14 @@ type LikeResolversUnitSuite struct {
|
|||||||
suite.Suite
|
suite.Suite
|
||||||
resolver *graphql.Resolver
|
resolver *graphql.Resolver
|
||||||
mockLikeRepo *testutil.MockLikeRepository
|
mockLikeRepo *testutil.MockLikeRepository
|
||||||
mockWorkRepo *testutil.MockWorkRepository
|
mockWorkRepo *mockWorkRepository
|
||||||
mockAnalyticsSvc *testutil.MockAnalyticsService
|
mockAnalyticsSvc *testutil.MockAnalyticsService
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *LikeResolversUnitSuite) SetupTest() {
|
func (s *LikeResolversUnitSuite) SetupTest() {
|
||||||
// 1. Create mock repositories
|
// 1. Create mock repositories
|
||||||
s.mockLikeRepo = new(testutil.MockLikeRepository)
|
s.mockLikeRepo = new(testutil.MockLikeRepository)
|
||||||
s.mockWorkRepo = new(testutil.MockWorkRepository)
|
s.mockWorkRepo = new(mockWorkRepository)
|
||||||
s.mockAnalyticsSvc = new(testutil.MockAnalyticsService)
|
s.mockAnalyticsSvc = new(testutil.MockAnalyticsService)
|
||||||
|
|
||||||
// 2. Create real services with mock repositories
|
// 2. Create real services with mock repositories
|
||||||
|
|||||||
@ -45,11 +45,11 @@ type Author struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AuthorInput struct {
|
type AuthorInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name" validate:"required,min=3,max=255"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language" validate:"required,len=2"`
|
||||||
Biography *string `json:"biography,omitempty"`
|
Biography *string `json:"biography,omitempty"`
|
||||||
BirthDate *string `json:"birthDate,omitempty"`
|
BirthDate *string `json:"birthDate,omitempty" validate:"omitempty,datetime=2006-01-02"`
|
||||||
DeathDate *string `json:"deathDate,omitempty"`
|
DeathDate *string `json:"deathDate,omitempty" validate:"omitempty,datetime=2006-01-02"`
|
||||||
CountryID *string `json:"countryId,omitempty"`
|
CountryID *string `json:"countryId,omitempty"`
|
||||||
CityID *string `json:"cityId,omitempty"`
|
CityID *string `json:"cityId,omitempty"`
|
||||||
PlaceID *string `json:"placeId,omitempty"`
|
PlaceID *string `json:"placeId,omitempty"`
|
||||||
@ -395,10 +395,10 @@ type Translation struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TranslationInput struct {
|
type TranslationInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name" validate:"required,min=3,max=255"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language" validate:"required,len=2"`
|
||||||
Content *string `json:"content,omitempty"`
|
Content *string `json:"content,omitempty"`
|
||||||
WorkID string `json:"workId"`
|
WorkID string `json:"workId" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TranslationStats struct {
|
type TranslationStats struct {
|
||||||
@ -442,14 +442,14 @@ type User struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserInput struct {
|
type UserInput struct {
|
||||||
Username *string `json:"username,omitempty"`
|
Username *string `json:"username,omitempty" validate:"omitempty,min=3,max=50"`
|
||||||
Email *string `json:"email,omitempty"`
|
Email *string `json:"email,omitempty" validate:"omitempty,email"`
|
||||||
Password *string `json:"password,omitempty"`
|
Password *string `json:"password,omitempty" validate:"omitempty,min=8"`
|
||||||
FirstName *string `json:"firstName,omitempty"`
|
FirstName *string `json:"firstName,omitempty" validate:"omitempty,min=2,max=50"`
|
||||||
LastName *string `json:"lastName,omitempty"`
|
LastName *string `json:"lastName,omitempty" validate:"omitempty,min=2,max=50"`
|
||||||
DisplayName *string `json:"displayName,omitempty"`
|
DisplayName *string `json:"displayName,omitempty"`
|
||||||
Bio *string `json:"bio,omitempty"`
|
Bio *string `json:"bio,omitempty"`
|
||||||
AvatarURL *string `json:"avatarUrl,omitempty"`
|
AvatarURL *string `json:"avatarUrl,omitempty" validate:"omitempty,url"`
|
||||||
Role *UserRole `json:"role,omitempty"`
|
Role *UserRole `json:"role,omitempty"`
|
||||||
Verified *bool `json:"verified,omitempty"`
|
Verified *bool `json:"verified,omitempty"`
|
||||||
Active *bool `json:"active,omitempty"`
|
Active *bool `json:"active,omitempty"`
|
||||||
@ -521,8 +521,8 @@ type Work struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WorkInput struct {
|
type WorkInput struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name" validate:"required,min=3,max=255"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language" validate:"required,len=2"`
|
||||||
Content *string `json:"content,omitempty"`
|
Content *string `json:"content,omitempty"`
|
||||||
AuthorIds []string `json:"authorIds,omitempty"`
|
AuthorIds []string `json:"authorIds,omitempty"`
|
||||||
TagIds []string `json:"tagIds,omitempty"`
|
TagIds []string `json:"tagIds,omitempty"`
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import (
|
|||||||
"tercul/internal/app/comment"
|
"tercul/internal/app/comment"
|
||||||
"tercul/internal/app/like"
|
"tercul/internal/app/like"
|
||||||
"tercul/internal/app/translation"
|
"tercul/internal/app/translation"
|
||||||
|
"tercul/internal/app/user"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
"tercul/internal/domain/work"
|
"tercul/internal/domain/work"
|
||||||
platform_auth "tercul/internal/platform/auth"
|
platform_auth "tercul/internal/platform/auth"
|
||||||
@ -88,8 +89,8 @@ func (r *mutationResolver) Login(ctx context.Context, input model.LoginInput) (*
|
|||||||
|
|
||||||
// CreateWork is the resolver for the createWork field.
|
// CreateWork is the resolver for the createWork field.
|
||||||
func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) {
|
func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) {
|
||||||
if err := validateWorkInput(input); err != nil {
|
if err := Validate(input); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
// Create domain model
|
// Create domain model
|
||||||
workModel := &work.Work{
|
workModel := &work.Work{
|
||||||
@ -131,8 +132,8 @@ func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput
|
|||||||
|
|
||||||
// UpdateWork is the resolver for the updateWork field.
|
// UpdateWork is the resolver for the updateWork field.
|
||||||
func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) {
|
func (r *mutationResolver) UpdateWork(ctx context.Context, id string, input model.WorkInput) (*model.Work, error) {
|
||||||
if err := validateWorkInput(input); err != nil {
|
if err := Validate(input); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
workID, err := strconv.ParseUint(id, 10, 32)
|
workID, err := strconv.ParseUint(id, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -180,8 +181,8 @@ func (r *mutationResolver) DeleteWork(ctx context.Context, id string) (bool, err
|
|||||||
|
|
||||||
// CreateTranslation is the resolver for the createTranslation field.
|
// CreateTranslation is the resolver for the createTranslation field.
|
||||||
func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) {
|
func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.TranslationInput) (*model.Translation, error) {
|
||||||
if err := validateTranslationInput(input); err != nil {
|
if err := Validate(input); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
workID, err := strconv.ParseUint(input.WorkID, 10, 32)
|
workID, err := strconv.ParseUint(input.WorkID, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -227,8 +228,8 @@ func (r *mutationResolver) CreateTranslation(ctx context.Context, input model.Tr
|
|||||||
|
|
||||||
// UpdateTranslation is the resolver for the updateTranslation field.
|
// UpdateTranslation is the resolver for the updateTranslation field.
|
||||||
func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) {
|
func (r *mutationResolver) UpdateTranslation(ctx context.Context, id string, input model.TranslationInput) (*model.Translation, error) {
|
||||||
if err := validateTranslationInput(input); err != nil {
|
if err := Validate(input); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
translationID, err := strconv.ParseUint(id, 10, 32)
|
translationID, err := strconv.ParseUint(id, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -276,8 +277,8 @@ func (r *mutationResolver) DeleteTranslation(ctx context.Context, id string) (bo
|
|||||||
|
|
||||||
// CreateAuthor is the resolver for the createAuthor field.
|
// CreateAuthor is the resolver for the createAuthor field.
|
||||||
func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) {
|
func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorInput) (*model.Author, error) {
|
||||||
if err := validateAuthorInput(input); err != nil {
|
if err := Validate(input); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
// Call author service
|
// Call author service
|
||||||
createInput := author.CreateAuthorInput{
|
createInput := author.CreateAuthorInput{
|
||||||
@ -298,8 +299,8 @@ func (r *mutationResolver) CreateAuthor(ctx context.Context, input model.AuthorI
|
|||||||
|
|
||||||
// UpdateAuthor is the resolver for the updateAuthor field.
|
// UpdateAuthor is the resolver for the updateAuthor field.
|
||||||
func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) {
|
func (r *mutationResolver) UpdateAuthor(ctx context.Context, id string, input model.AuthorInput) (*model.Author, error) {
|
||||||
if err := validateAuthorInput(input); err != nil {
|
if err := Validate(input); err != nil {
|
||||||
return nil, fmt.Errorf("%w: %v", ErrValidation, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
authorID, err := strconv.ParseUint(id, 10, 32)
|
authorID, err := strconv.ParseUint(id, 10, 32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -341,7 +342,78 @@ func (r *mutationResolver) DeleteAuthor(ctx context.Context, id string) (bool, e
|
|||||||
|
|
||||||
// UpdateUser is the resolver for the updateUser field.
|
// UpdateUser is the resolver for the updateUser field.
|
||||||
func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input model.UserInput) (*model.User, error) {
|
func (r *mutationResolver) UpdateUser(ctx context.Context, id string, input model.UserInput) (*model.User, error) {
|
||||||
panic(fmt.Errorf("not implemented: UpdateUser - updateUser"))
|
if err := Validate(input); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.ParseUint(id, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid user ID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInput := user.UpdateUserInput{
|
||||||
|
ID: uint(userID),
|
||||||
|
Username: input.Username,
|
||||||
|
Email: input.Email,
|
||||||
|
Password: input.Password,
|
||||||
|
FirstName: input.FirstName,
|
||||||
|
LastName: input.LastName,
|
||||||
|
DisplayName: input.DisplayName,
|
||||||
|
Bio: input.Bio,
|
||||||
|
AvatarURL: input.AvatarURL,
|
||||||
|
Verified: input.Verified,
|
||||||
|
Active: input.Active,
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.Role != nil {
|
||||||
|
role := domain.UserRole(input.Role.String())
|
||||||
|
updateInput.Role = &role
|
||||||
|
}
|
||||||
|
|
||||||
|
if input.CountryID != nil {
|
||||||
|
countryID, err := strconv.ParseUint(*input.CountryID, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid country ID: %v", err)
|
||||||
|
}
|
||||||
|
uid := uint(countryID)
|
||||||
|
updateInput.CountryID = &uid
|
||||||
|
}
|
||||||
|
if input.CityID != nil {
|
||||||
|
cityID, err := strconv.ParseUint(*input.CityID, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid city ID: %v", err)
|
||||||
|
}
|
||||||
|
uid := uint(cityID)
|
||||||
|
updateInput.CityID = &uid
|
||||||
|
}
|
||||||
|
if input.AddressID != nil {
|
||||||
|
addressID, err := strconv.ParseUint(*input.AddressID, 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid address ID: %v", err)
|
||||||
|
}
|
||||||
|
uid := uint(addressID)
|
||||||
|
updateInput.AddressID = &uid
|
||||||
|
}
|
||||||
|
|
||||||
|
updatedUser, err := r.App.User.Commands.UpdateUser(ctx, updateInput)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to GraphQL model
|
||||||
|
return &model.User{
|
||||||
|
ID: fmt.Sprintf("%d", updatedUser.ID),
|
||||||
|
Username: updatedUser.Username,
|
||||||
|
Email: updatedUser.Email,
|
||||||
|
FirstName: &updatedUser.FirstName,
|
||||||
|
LastName: &updatedUser.LastName,
|
||||||
|
DisplayName: &updatedUser.DisplayName,
|
||||||
|
Bio: &updatedUser.Bio,
|
||||||
|
AvatarURL: &updatedUser.AvatarURL,
|
||||||
|
Role: model.UserRole(updatedUser.Role),
|
||||||
|
Verified: updatedUser.Verified,
|
||||||
|
Active: updatedUser.Active,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser is the resolver for the deleteUser field.
|
// DeleteUser is the resolver for the deleteUser field.
|
||||||
|
|||||||
@ -4,54 +4,30 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"tercul/internal/adapters/graphql/model"
|
"tercul/internal/domain"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrValidation = errors.New("validation failed")
|
// The 'validate' variable is declared in binding.go and is used here.
|
||||||
|
|
||||||
func validateWorkInput(input model.WorkInput) error {
|
// Validate performs validation on a struct using the validator library.
|
||||||
name := strings.TrimSpace(input.Name)
|
func Validate(s interface{}) error {
|
||||||
if len(name) < 3 {
|
err := validate.Struct(s)
|
||||||
return fmt.Errorf("name must be at least 3 characters long")
|
if err == nil {
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
|
|
||||||
return fmt.Errorf("name can only contain letters, numbers, and spaces")
|
|
||||||
}
|
|
||||||
if len(input.Language) != 2 {
|
|
||||||
return fmt.Errorf("language must be a 2-character code")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAuthorInput(input model.AuthorInput) error {
|
var validationErrors validator.ValidationErrors
|
||||||
name := strings.TrimSpace(input.Name)
|
if errors.As(err, &validationErrors) {
|
||||||
if len(name) < 3 {
|
var errorMessages []string
|
||||||
return fmt.Errorf("name must be at least 3 characters long")
|
for _, err := range validationErrors {
|
||||||
|
// Customize error messages here if needed.
|
||||||
|
errorMessages = append(errorMessages, fmt.Sprintf("field '%s' failed on the '%s' tag", err.Field(), err.Tag()))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("%w: %s", domain.ErrValidation, strings.Join(errorMessages, "; "))
|
||||||
}
|
}
|
||||||
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
|
|
||||||
return fmt.Errorf("name can only contain letters, numbers, and spaces")
|
|
||||||
}
|
|
||||||
if len(input.Language) != 2 {
|
|
||||||
return fmt.Errorf("language must be a 2-character code")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateTranslationInput(input model.TranslationInput) error {
|
// For other unexpected errors, like invalid validation input.
|
||||||
name := strings.TrimSpace(input.Name)
|
return fmt.Errorf("unexpected error during validation: %w", err)
|
||||||
if len(name) < 3 {
|
|
||||||
return fmt.Errorf("name must be at least 3 characters long")
|
|
||||||
}
|
|
||||||
if !govalidator.Matches(name, `^[a-zA-Z0-9\s]+$`) {
|
|
||||||
return fmt.Errorf("name can only contain letters, numbers, and spaces")
|
|
||||||
}
|
|
||||||
if len(input.Language) != 2 {
|
|
||||||
return fmt.Errorf("language must be a 2-character code")
|
|
||||||
}
|
|
||||||
if input.WorkID == "" {
|
|
||||||
return fmt.Errorf("workId is required")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
101
internal/adapters/graphql/work_repo_mock_test.go
Normal file
101
internal/adapters/graphql/work_repo_mock_test.go
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
package graphql_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
"tercul/internal/domain/work"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockWorkRepository is a mock implementation of the WorkRepository interface.
|
||||||
|
type mockWorkRepository struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkRepository) Create(ctx context.Context, entity *work.Work) error {
|
||||||
|
args := m.Called(ctx, entity)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
|
||||||
|
return m.Create(ctx, entity)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*work.Work), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) {
|
||||||
|
return m.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) Update(ctx context.Context, entity *work.Work) error {
|
||||||
|
args := m.Called(ctx, entity)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
|
||||||
|
return m.Update(ctx, entity)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) Delete(ctx context.Context, id uint) error {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
|
||||||
|
return m.Delete(ctx, id)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { panic("not implemented") }
|
||||||
|
func (m *mockWorkRepository) Count(ctx context.Context) (int64, error) {
|
||||||
|
args := m.Called(ctx)
|
||||||
|
return args.Get(0).(int64), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) {
|
||||||
|
return m.GetByID(ctx, id)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil }
|
||||||
|
func (m *mockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||||
|
return fn(nil)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) {
|
||||||
|
args := m.Called(ctx, id)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(*work.Work), args.Error(1)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||||
|
args := m.Called(ctx, workID, authorID)
|
||||||
|
return args.Bool(0), args.Error(1)
|
||||||
|
}
|
||||||
@ -19,6 +19,8 @@ import (
|
|||||||
platform_auth "tercul/internal/platform/auth"
|
platform_auth "tercul/internal/platform/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
import "tercul/internal/app/authz"
|
||||||
|
|
||||||
// Application is a container for all the application-layer services.
|
// Application is a container for all the application-layer services.
|
||||||
type Application struct {
|
type Application struct {
|
||||||
Author *author.Service
|
Author *author.Service
|
||||||
@ -32,24 +34,26 @@ type Application struct {
|
|||||||
User *user.Service
|
User *user.Service
|
||||||
Localization *localization.Service
|
Localization *localization.Service
|
||||||
Auth *auth.Service
|
Auth *auth.Service
|
||||||
|
Authz *authz.Service
|
||||||
Work *work.Service
|
Work *work.Service
|
||||||
Analytics analytics.Service
|
Analytics analytics.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application {
|
func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, analyticsService analytics.Service) *Application {
|
||||||
jwtManager := platform_auth.NewJWTManager()
|
jwtManager := platform_auth.NewJWTManager()
|
||||||
|
authzService := authz.NewService(repos.Work)
|
||||||
authorService := author.NewService(repos.Author)
|
authorService := author.NewService(repos.Author)
|
||||||
bookmarkService := bookmark.NewService(repos.Bookmark)
|
bookmarkService := bookmark.NewService(repos.Bookmark)
|
||||||
categoryService := category.NewService(repos.Category)
|
categoryService := category.NewService(repos.Category)
|
||||||
collectionService := collection.NewService(repos.Collection)
|
collectionService := collection.NewService(repos.Collection)
|
||||||
commentService := comment.NewService(repos.Comment)
|
commentService := comment.NewService(repos.Comment, authzService)
|
||||||
likeService := like.NewService(repos.Like)
|
likeService := like.NewService(repos.Like)
|
||||||
tagService := tag.NewService(repos.Tag)
|
tagService := tag.NewService(repos.Tag)
|
||||||
translationService := translation.NewService(repos.Translation)
|
translationService := translation.NewService(repos.Translation)
|
||||||
userService := user.NewService(repos.User)
|
userService := user.NewService(repos.User, authzService)
|
||||||
localizationService := localization.NewService(repos.Localization)
|
localizationService := localization.NewService(repos.Localization)
|
||||||
authService := auth.NewService(repos.User, jwtManager)
|
authService := auth.NewService(repos.User, jwtManager)
|
||||||
workService := work.NewService(repos.Work, searchClient)
|
workService := work.NewService(repos.Work, searchClient, authzService)
|
||||||
|
|
||||||
return &Application{
|
return &Application{
|
||||||
Author: authorService,
|
Author: authorService,
|
||||||
@ -63,6 +67,7 @@ func NewApplication(repos *sql.Repositories, searchClient search.SearchClient, a
|
|||||||
User: userService,
|
User: userService,
|
||||||
Localization: localizationService,
|
Localization: localizationService,
|
||||||
Auth: authService,
|
Auth: authService,
|
||||||
|
Authz: authzService,
|
||||||
Work: workService,
|
Work: workService,
|
||||||
Analytics: analyticsService,
|
Analytics: analyticsService,
|
||||||
}
|
}
|
||||||
|
|||||||
84
internal/app/authz/authz.go
Normal file
84
internal/app/authz/authz.go
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
package authz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
"tercul/internal/domain/work"
|
||||||
|
platform_auth "tercul/internal/platform/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Service provides authorization checks for the application.
|
||||||
|
type Service struct {
|
||||||
|
workRepo work.WorkRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewService creates a new authorization service.
|
||||||
|
func NewService(workRepo work.WorkRepository) *Service {
|
||||||
|
return &Service{workRepo: workRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanEditWork checks if a user has permission to edit a work.
|
||||||
|
// For now, we'll implement a simple rule: only an admin or the work's author can edit it.
|
||||||
|
func (s *Service) CanEditWork(ctx context.Context, userID uint, work *work.Work) (bool, error) {
|
||||||
|
claims, ok := platform_auth.GetClaimsFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return false, domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins can do anything.
|
||||||
|
if claims.Role == string(domain.UserRoleAdmin) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user is an author of the work.
|
||||||
|
isAuthor, err := s.workRepo.IsAuthor(ctx, work.ID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if isAuthor {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanUpdateUser checks if a user has permission to update another user's profile.
|
||||||
|
func (s *Service) CanUpdateUser(ctx context.Context, actorID, targetUserID uint) (bool, error) {
|
||||||
|
claims, ok := platform_auth.GetClaimsFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return false, domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins can do anything.
|
||||||
|
if claims.Role == string(domain.UserRoleAdmin) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Users can update their own profile.
|
||||||
|
if actorID == targetUserID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanDeleteComment checks if a user has permission to delete a comment.
|
||||||
|
// For now, we'll implement a simple rule: only an admin or the comment's author can delete it.
|
||||||
|
func (s *Service) CanDeleteComment(ctx context.Context, userID uint, comment *domain.Comment) (bool, error) {
|
||||||
|
claims, ok := platform_auth.GetClaimsFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return false, domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins can do anything.
|
||||||
|
if claims.Role == string(domain.UserRoleAdmin) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the user is the author of the comment.
|
||||||
|
if comment.UserID == userID {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, domain.ErrForbidden
|
||||||
|
}
|
||||||
@ -2,17 +2,27 @@ package comment
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"tercul/internal/app/authz"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
|
platform_auth "tercul/internal/platform/auth"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CommentCommands contains the command handlers for the comment aggregate.
|
// CommentCommands contains the command handlers for the comment aggregate.
|
||||||
type CommentCommands struct {
|
type CommentCommands struct {
|
||||||
repo domain.CommentRepository
|
repo domain.CommentRepository
|
||||||
|
authzSvc *authz.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCommentCommands creates a new CommentCommands handler.
|
// NewCommentCommands creates a new CommentCommands handler.
|
||||||
func NewCommentCommands(repo domain.CommentRepository) *CommentCommands {
|
func NewCommentCommands(repo domain.CommentRepository, authzSvc *authz.Service) *CommentCommands {
|
||||||
return &CommentCommands{repo: repo}
|
return &CommentCommands{
|
||||||
|
repo: repo,
|
||||||
|
authzSvc: authzSvc,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateCommentInput represents the input for creating a new comment.
|
// CreateCommentInput represents the input for creating a new comment.
|
||||||
@ -46,12 +56,29 @@ type UpdateCommentInput struct {
|
|||||||
Text string
|
Text string
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateComment updates an existing comment.
|
// UpdateComment updates an existing comment after an authorization check.
|
||||||
func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) {
|
func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateCommentInput) (*domain.Comment, error) {
|
||||||
|
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
comment, err := c.repo.GetByID(ctx, input.ID)
|
comment, err := c.repo.GetByID(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, input.ID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment) // Using CanDeleteComment for editing as well
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !can {
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
comment.Text = input.Text
|
comment.Text = input.Text
|
||||||
err = c.repo.Update(ctx, comment)
|
err = c.repo.Update(ctx, comment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -60,7 +87,28 @@ func (c *CommentCommands) UpdateComment(ctx context.Context, input UpdateComment
|
|||||||
return comment, nil
|
return comment, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteComment deletes a comment by ID.
|
// DeleteComment deletes a comment by ID after an authorization check.
|
||||||
func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error {
|
func (c *CommentCommands) DeleteComment(ctx context.Context, id uint) error {
|
||||||
|
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
comment, err := c.repo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("%w: comment with id %d not found", domain.ErrNotFound, id)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
can, err := c.authzSvc.CanDeleteComment(ctx, userID, comment)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !can {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
return c.repo.Delete(ctx, id)
|
return c.repo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
@ -1,6 +1,9 @@
|
|||||||
package comment
|
package comment
|
||||||
|
|
||||||
import "tercul/internal/domain"
|
import (
|
||||||
|
"tercul/internal/app/authz"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
// Service is the application service for the comment aggregate.
|
// Service is the application service for the comment aggregate.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@ -9,9 +12,9 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new comment Service.
|
// NewService creates a new comment Service.
|
||||||
func NewService(repo domain.CommentRepository) *Service {
|
func NewService(repo domain.CommentRepository, authzSvc *authz.Service) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
Commands: NewCommentCommands(repo),
|
Commands: NewCommentCommands(repo, authzSvc),
|
||||||
Queries: NewCommentQueries(repo),
|
Queries: NewCommentQueries(repo),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,17 +2,27 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"tercul/internal/app/authz"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
|
platform_auth "tercul/internal/platform/auth"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserCommands contains the command handlers for the user aggregate.
|
// UserCommands contains the command handlers for the user aggregate.
|
||||||
type UserCommands struct {
|
type UserCommands struct {
|
||||||
repo domain.UserRepository
|
repo domain.UserRepository
|
||||||
|
authzSvc *authz.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewUserCommands creates a new UserCommands handler.
|
// NewUserCommands creates a new UserCommands handler.
|
||||||
func NewUserCommands(repo domain.UserRepository) *UserCommands {
|
func NewUserCommands(repo domain.UserRepository, authzSvc *authz.Service) *UserCommands {
|
||||||
return &UserCommands{repo: repo}
|
return &UserCommands{
|
||||||
|
repo: repo,
|
||||||
|
authzSvc: authzSvc,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUserInput represents the input for creating a new user.
|
// CreateUserInput represents the input for creating a new user.
|
||||||
@ -44,25 +54,92 @@ func (c *UserCommands) CreateUser(ctx context.Context, input CreateUserInput) (*
|
|||||||
|
|
||||||
// UpdateUserInput represents the input for updating an existing user.
|
// UpdateUserInput represents the input for updating an existing user.
|
||||||
type UpdateUserInput struct {
|
type UpdateUserInput struct {
|
||||||
ID uint
|
ID uint
|
||||||
Username string
|
Username *string
|
||||||
Email string
|
Email *string
|
||||||
FirstName string
|
Password *string
|
||||||
LastName string
|
FirstName *string
|
||||||
Role domain.UserRole
|
LastName *string
|
||||||
|
DisplayName *string
|
||||||
|
Bio *string
|
||||||
|
AvatarURL *string
|
||||||
|
Role *domain.UserRole
|
||||||
|
Verified *bool
|
||||||
|
Active *bool
|
||||||
|
CountryID *uint
|
||||||
|
CityID *uint
|
||||||
|
AddressID *uint
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser updates an existing user.
|
// UpdateUser updates an existing user.
|
||||||
func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*domain.User, error) {
|
func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*domain.User, error) {
|
||||||
user, err := c.repo.GetByID(ctx, input.ID)
|
actorID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return nil, domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
can, err := c.authzSvc.CanUpdateUser(ctx, actorID, input.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
user.Username = input.Username
|
if !can {
|
||||||
user.Email = input.Email
|
return nil, domain.ErrForbidden
|
||||||
user.FirstName = input.FirstName
|
}
|
||||||
user.LastName = input.LastName
|
|
||||||
user.Role = input.Role
|
user, err := c.repo.GetByID(ctx, input.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, fmt.Errorf("%w: user with id %d not found", domain.ErrNotFound, input.ID)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply partial updates
|
||||||
|
if input.Username != nil {
|
||||||
|
user.Username = *input.Username
|
||||||
|
}
|
||||||
|
if input.Email != nil {
|
||||||
|
user.Email = *input.Email
|
||||||
|
}
|
||||||
|
if input.Password != nil {
|
||||||
|
if err := user.SetPassword(*input.Password); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if input.FirstName != nil {
|
||||||
|
user.FirstName = *input.FirstName
|
||||||
|
}
|
||||||
|
if input.LastName != nil {
|
||||||
|
user.LastName = *input.LastName
|
||||||
|
}
|
||||||
|
if input.DisplayName != nil {
|
||||||
|
user.DisplayName = *input.DisplayName
|
||||||
|
}
|
||||||
|
if input.Bio != nil {
|
||||||
|
user.Bio = *input.Bio
|
||||||
|
}
|
||||||
|
if input.AvatarURL != nil {
|
||||||
|
user.AvatarURL = *input.AvatarURL
|
||||||
|
}
|
||||||
|
if input.Role != nil {
|
||||||
|
user.Role = *input.Role
|
||||||
|
}
|
||||||
|
if input.Verified != nil {
|
||||||
|
user.Verified = *input.Verified
|
||||||
|
}
|
||||||
|
if input.Active != nil {
|
||||||
|
user.Active = *input.Active
|
||||||
|
}
|
||||||
|
if input.CountryID != nil {
|
||||||
|
user.CountryID = input.CountryID
|
||||||
|
}
|
||||||
|
if input.CityID != nil {
|
||||||
|
user.CityID = input.CityID
|
||||||
|
}
|
||||||
|
if input.AddressID != nil {
|
||||||
|
user.AddressID = input.AddressID
|
||||||
|
}
|
||||||
|
|
||||||
err = c.repo.Update(ctx, user)
|
err = c.repo.Update(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -72,5 +149,18 @@ func (c *UserCommands) UpdateUser(ctx context.Context, input UpdateUserInput) (*
|
|||||||
|
|
||||||
// DeleteUser deletes a user by ID.
|
// DeleteUser deletes a user by ID.
|
||||||
func (c *UserCommands) DeleteUser(ctx context.Context, id uint) error {
|
func (c *UserCommands) DeleteUser(ctx context.Context, id uint) error {
|
||||||
|
actorID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
can, err := c.authzSvc.CanUpdateUser(ctx, actorID, id) // Re-using CanUpdateUser for deletion
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !can {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
return c.repo.Delete(ctx, id)
|
return c.repo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
102
internal/app/user/commands_test.go
Normal file
102
internal/app/user/commands_test.go
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"tercul/internal/app/authz"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
platform_auth "tercul/internal/platform/auth"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/suite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserCommandsSuite struct {
|
||||||
|
suite.Suite
|
||||||
|
repo *mockUserRepository
|
||||||
|
authzSvc *authz.Service
|
||||||
|
commands *UserCommands
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserCommandsSuite) SetupTest() {
|
||||||
|
s.repo = &mockUserRepository{}
|
||||||
|
workRepo := &mockWorkRepoForUserTests{}
|
||||||
|
s.authzSvc = authz.NewService(workRepo)
|
||||||
|
s.commands = NewUserCommands(s.repo, s.authzSvc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserCommandsSuite(t *testing.T) {
|
||||||
|
suite.Run(t, new(UserCommandsSuite))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserCommandsSuite) TestUpdateUser_Success_Self() {
|
||||||
|
// Arrange
|
||||||
|
ctx := platform_auth.ContextWithUserID(context.Background(), 1)
|
||||||
|
input := UpdateUserInput{ID: 1, Username: strPtr("new_username")}
|
||||||
|
|
||||||
|
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.User, error) {
|
||||||
|
return &domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
updatedUser, err := s.commands.UpdateUser(ctx, input)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.NoError(s.T(), err)
|
||||||
|
assert.NotNil(s.T(), updatedUser)
|
||||||
|
assert.Equal(s.T(), "new_username", updatedUser.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserCommandsSuite) TestUpdateUser_Success_Admin() {
|
||||||
|
// Arrange
|
||||||
|
ctx := platform_auth.ContextWithAdminUser(context.Background(), 99) // Admin user
|
||||||
|
input := UpdateUserInput{ID: 1, Username: strPtr("new_username_by_admin")}
|
||||||
|
|
||||||
|
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.User, error) {
|
||||||
|
return &domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
updatedUser, err := s.commands.UpdateUser(ctx, input)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.NoError(s.T(), err)
|
||||||
|
assert.NotNil(s.T(), updatedUser)
|
||||||
|
assert.Equal(s.T(), "new_username_by_admin", updatedUser.Username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserCommandsSuite) TestUpdateUser_Forbidden() {
|
||||||
|
// Arrange
|
||||||
|
ctx := platform_auth.ContextWithUserID(context.Background(), 2) // Different user
|
||||||
|
input := UpdateUserInput{ID: 1, Username: strPtr("forbidden_username")}
|
||||||
|
|
||||||
|
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*domain.User, error) {
|
||||||
|
return &domain.User{BaseModel: domain.BaseModel{ID: 1}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_, err := s.commands.UpdateUser(ctx, input)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Error(s.T(), err)
|
||||||
|
assert.ErrorIs(s.T(), err, domain.ErrForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserCommandsSuite) TestUpdateUser_Unauthorized() {
|
||||||
|
// Arrange
|
||||||
|
ctx := context.Background() // No user in context
|
||||||
|
input := UpdateUserInput{ID: 1, Username: strPtr("unauthorized_username")}
|
||||||
|
|
||||||
|
// Act
|
||||||
|
_, err := s.commands.UpdateUser(ctx, input)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert.Error(s.T(), err)
|
||||||
|
assert.ErrorIs(s.T(), err, domain.ErrUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to get a pointer to a string
|
||||||
|
func strPtr(s string) *string {
|
||||||
|
return &s
|
||||||
|
}
|
||||||
32
internal/app/user/main_test.go
Normal file
32
internal/app/user/main_test.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockUserRepository struct {
|
||||||
|
domain.UserRepository
|
||||||
|
createFunc func(ctx context.Context, user *domain.User) error
|
||||||
|
updateFunc func(ctx context.Context, user *domain.User) error
|
||||||
|
getByIDFunc func(ctx context.Context, id uint) (*domain.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockUserRepository) Create(ctx context.Context, user *domain.User) error {
|
||||||
|
if m.createFunc != nil {
|
||||||
|
return m.createFunc(ctx, user)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockUserRepository) Update(ctx context.Context, user *domain.User) error {
|
||||||
|
if m.updateFunc != nil {
|
||||||
|
return m.updateFunc(ctx, user)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockUserRepository) GetByID(ctx context.Context, id uint) (*domain.User, error) {
|
||||||
|
if m.getByIDFunc != nil {
|
||||||
|
return m.getByIDFunc(ctx, id)
|
||||||
|
}
|
||||||
|
return &domain.User{BaseModel: domain.BaseModel{ID: id}}, nil
|
||||||
|
}
|
||||||
@ -1,6 +1,9 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
import "tercul/internal/domain"
|
import (
|
||||||
|
"tercul/internal/app/authz"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
// Service is the application service for the user aggregate.
|
// Service is the application service for the user aggregate.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@ -9,9 +12,9 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new user Service.
|
// NewService creates a new user Service.
|
||||||
func NewService(repo domain.UserRepository) *Service {
|
func NewService(repo domain.UserRepository, authzSvc *authz.Service) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
Commands: NewUserCommands(repo),
|
Commands: NewUserCommands(repo, authzSvc),
|
||||||
Queries: NewUserQueries(repo),
|
Queries: NewUserQueries(repo),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
71
internal/app/user/work_repo_mock_test.go
Normal file
71
internal/app/user/work_repo_mock_test.go
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"tercul/internal/domain"
|
||||||
|
"tercul/internal/domain/work"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mockWorkRepoForUserTests struct{}
|
||||||
|
|
||||||
|
func (m *mockWorkRepoForUserTests) Create(ctx context.Context, entity *work.Work) error { return nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) GetByID(ctx context.Context, id uint) (*work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) Update(ctx context.Context, entity *work.Work) error { return nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) Delete(ctx context.Context, id uint) error { return nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) ListAll(ctx context.Context) ([]work.Work, error) { return nil, nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) Count(ctx context.Context) (int64, error) { return 0, nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) Exists(ctx context.Context, id uint) (bool, error) { return false, nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil }
|
||||||
|
func (m *mockWorkRepoForUserTests) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
||||||
|
return fn(nil)
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) FindByTitle(ctx context.Context, title string) ([]work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
func (m *mockWorkRepoForUserTests) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@ -3,21 +3,29 @@ package work
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"tercul/internal/app/authz"
|
||||||
|
"tercul/internal/domain"
|
||||||
"tercul/internal/domain/search"
|
"tercul/internal/domain/search"
|
||||||
"tercul/internal/domain/work"
|
"tercul/internal/domain/work"
|
||||||
|
platform_auth "tercul/internal/platform/auth"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// WorkCommands contains the command handlers for the work aggregate.
|
// WorkCommands contains the command handlers for the work aggregate.
|
||||||
type WorkCommands struct {
|
type WorkCommands struct {
|
||||||
repo work.WorkRepository
|
repo work.WorkRepository
|
||||||
searchClient search.SearchClient
|
searchClient search.SearchClient
|
||||||
|
authzSvc *authz.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorkCommands creates a new WorkCommands handler.
|
// NewWorkCommands creates a new WorkCommands handler.
|
||||||
func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient) *WorkCommands {
|
func NewWorkCommands(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *WorkCommands {
|
||||||
return &WorkCommands{
|
return &WorkCommands{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
searchClient: searchClient,
|
searchClient: searchClient,
|
||||||
|
authzSvc: authzSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,21 +52,44 @@ func (c *WorkCommands) CreateWork(ctx context.Context, work *work.Work) (*work.W
|
|||||||
return work, nil
|
return work, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateWork updates an existing work.
|
// UpdateWork updates an existing work after performing an authorization check.
|
||||||
func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
|
func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
|
||||||
if work == nil {
|
if work == nil {
|
||||||
return errors.New("work cannot be nil")
|
return fmt.Errorf("%w: work cannot be nil", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
if work.ID == 0 {
|
if work.ID == 0 {
|
||||||
return errors.New("work ID cannot be zero")
|
return fmt.Errorf("%w: work ID cannot be zero", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
existingWork, err := c.repo.GetByID(ctx, work.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, work.ID)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to get work for authorization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
can, err := c.authzSvc.CanEditWork(ctx, userID, existingWork)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !can {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
if work.Title == "" {
|
if work.Title == "" {
|
||||||
return errors.New("work title cannot be empty")
|
return fmt.Errorf("%w: work title cannot be empty", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
if work.Language == "" {
|
if work.Language == "" {
|
||||||
return errors.New("work language cannot be empty")
|
return fmt.Errorf("%w: work language cannot be empty", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
err := c.repo.Update(ctx, work)
|
|
||||||
|
err = c.repo.Update(ctx, work)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -66,11 +97,33 @@ func (c *WorkCommands) UpdateWork(ctx context.Context, work *work.Work) error {
|
|||||||
return c.searchClient.IndexWork(ctx, work, "")
|
return c.searchClient.IndexWork(ctx, work, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteWork deletes a work by ID.
|
// DeleteWork deletes a work by ID after performing an authorization check.
|
||||||
func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
|
func (c *WorkCommands) DeleteWork(ctx context.Context, id uint) error {
|
||||||
if id == 0 {
|
if id == 0 {
|
||||||
return errors.New("invalid work ID")
|
return fmt.Errorf("%w: invalid work ID", domain.ErrValidation)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userID, ok := platform_auth.GetUserIDFromContext(ctx)
|
||||||
|
if !ok {
|
||||||
|
return domain.ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
existingWork, err := c.repo.GetByID(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return fmt.Errorf("%w: work with id %d not found", domain.ErrNotFound, id)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("failed to get work for authorization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
can, err := c.authzSvc.CanEditWork(ctx, userID, existingWork) // Re-using CanEditWork for deletion for now
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !can {
|
||||||
|
return domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
return c.repo.Delete(ctx, id)
|
return c.repo.Delete(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,8 +5,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/suite"
|
"github.com/stretchr/testify/suite"
|
||||||
|
"tercul/internal/app/authz"
|
||||||
"tercul/internal/domain"
|
"tercul/internal/domain"
|
||||||
workdomain "tercul/internal/domain/work"
|
workdomain "tercul/internal/domain/work"
|
||||||
|
platform_auth "tercul/internal/platform/auth"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -14,13 +16,15 @@ type WorkCommandsSuite struct {
|
|||||||
suite.Suite
|
suite.Suite
|
||||||
repo *mockWorkRepository
|
repo *mockWorkRepository
|
||||||
searchClient *mockSearchClient
|
searchClient *mockSearchClient
|
||||||
|
authzSvc *authz.Service
|
||||||
commands *WorkCommands
|
commands *WorkCommands
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkCommandsSuite) SetupTest() {
|
func (s *WorkCommandsSuite) SetupTest() {
|
||||||
s.repo = &mockWorkRepository{}
|
s.repo = &mockWorkRepository{}
|
||||||
s.searchClient = &mockSearchClient{}
|
s.searchClient = &mockSearchClient{}
|
||||||
s.commands = NewWorkCommands(s.repo, s.searchClient)
|
s.authzSvc = authz.NewService(s.repo)
|
||||||
|
s.commands = NewWorkCommands(s.repo, s.searchClient, s.authzSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWorkCommandsSuite(t *testing.T) {
|
func TestWorkCommandsSuite(t *testing.T) {
|
||||||
@ -60,9 +64,18 @@ func (s *WorkCommandsSuite) TestCreateWork_RepoError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkCommandsSuite) TestUpdateWork_Success() {
|
func (s *WorkCommandsSuite) TestUpdateWork_Success() {
|
||||||
|
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
|
||||||
work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
|
work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
|
||||||
work.ID = 1
|
work.ID = 1
|
||||||
err := s.commands.UpdateWork(context.Background(), work)
|
|
||||||
|
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) {
|
||||||
|
return work, nil
|
||||||
|
}
|
||||||
|
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.commands.UpdateWork(ctx, work)
|
||||||
assert.NoError(s.T(), err)
|
assert.NoError(s.T(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,7 +115,18 @@ func (s *WorkCommandsSuite) TestUpdateWork_RepoError() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *WorkCommandsSuite) TestDeleteWork_Success() {
|
func (s *WorkCommandsSuite) TestDeleteWork_Success() {
|
||||||
err := s.commands.DeleteWork(context.Background(), 1)
|
ctx := platform_auth.ContextWithAdminUser(context.Background(), 1)
|
||||||
|
work := &workdomain.Work{Title: "Test Work", TranslatableModel: domain.TranslatableModel{Language: "en"}}
|
||||||
|
work.ID = 1
|
||||||
|
|
||||||
|
s.repo.getByIDFunc = func(ctx context.Context, id uint) (*workdomain.Work, error) {
|
||||||
|
return work, nil
|
||||||
|
}
|
||||||
|
s.repo.isAuthorFunc = func(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := s.commands.DeleteWork(ctx, 1)
|
||||||
assert.NoError(s.T(), err)
|
assert.NoError(s.T(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,14 @@ type mockWorkRepository struct {
|
|||||||
findByAuthorFunc func(ctx context.Context, authorID uint) ([]work.Work, error)
|
findByAuthorFunc func(ctx context.Context, authorID uint) ([]work.Work, error)
|
||||||
findByCategoryFunc func(ctx context.Context, categoryID uint) ([]work.Work, error)
|
findByCategoryFunc func(ctx context.Context, categoryID uint) ([]work.Work, error)
|
||||||
findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error)
|
findByLanguageFunc func(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error)
|
||||||
|
isAuthorFunc func(ctx context.Context, workID uint, authorID uint) (bool, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||||
|
if m.isAuthorFunc != nil {
|
||||||
|
return m.isAuthorFunc(ctx, workID, authorID)
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockWorkRepository) Create(ctx context.Context, work *work.Work) error {
|
func (m *mockWorkRepository) Create(ctx context.Context, work *work.Work) error {
|
||||||
@ -42,7 +50,7 @@ func (m *mockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work,
|
|||||||
if m.getByIDFunc != nil {
|
if m.getByIDFunc != nil {
|
||||||
return m.getByIDFunc(ctx, id)
|
return m.getByIDFunc(ctx, id)
|
||||||
}
|
}
|
||||||
return nil, nil
|
return &work.Work{TranslatableModel: domain.TranslatableModel{BaseModel: domain.BaseModel{ID: id}}}, nil
|
||||||
}
|
}
|
||||||
func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
if m.listFunc != nil {
|
if m.listFunc != nil {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package work
|
package work
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"tercul/internal/app/authz"
|
||||||
"tercul/internal/domain/search"
|
"tercul/internal/domain/search"
|
||||||
"tercul/internal/domain/work"
|
"tercul/internal/domain/work"
|
||||||
)
|
)
|
||||||
@ -12,9 +13,9 @@ type Service struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new work Service.
|
// NewService creates a new work Service.
|
||||||
func NewService(repo work.WorkRepository, searchClient search.SearchClient) *Service {
|
func NewService(repo work.WorkRepository, searchClient search.SearchClient, authzSvc *authz.Service) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
Commands: NewWorkCommands(repo, searchClient),
|
Commands: NewWorkCommands(repo, searchClient, authzSvc),
|
||||||
Queries: NewWorkQueries(repo),
|
Queries: NewWorkQueries(repo),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -120,6 +120,21 @@ func (r *workRepository) GetWithTranslations(ctx context.Context, id uint) (*wor
|
|||||||
return r.FindWithPreload(ctx, []string{"Translations"}, id)
|
return r.FindWithPreload(ctx, []string{"Translations"}, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAuthor checks if a user is an author of a work.
|
||||||
|
// Note: This assumes a direct relationship between user ID and author ID,
|
||||||
|
// which may need to be revised based on the actual domain model.
|
||||||
|
func (r *workRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.WithContext(ctx).
|
||||||
|
Table("work_authors").
|
||||||
|
Where("work_id = ? AND author_id = ?", workID, authorID).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListWithTranslations lists works with their translations
|
// ListWithTranslations lists works with their translations
|
||||||
func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
func (r *workRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
|
|||||||
20
internal/domain/errors.go
Normal file
20
internal/domain/errors.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// ErrNotFound indicates that a requested resource was not found.
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
// ErrUnauthorized indicates that the user is not authenticated.
|
||||||
|
ErrUnauthorized = errors.New("unauthorized")
|
||||||
|
|
||||||
|
// ErrForbidden indicates that the user is authenticated but not authorized to perform the action.
|
||||||
|
ErrForbidden = errors.New("forbidden")
|
||||||
|
|
||||||
|
// ErrValidation indicates that the input failed validation.
|
||||||
|
ErrValidation = errors.New("validation failed")
|
||||||
|
|
||||||
|
// ErrConflict indicates a conflict with the current state of the resource (e.g., duplicate).
|
||||||
|
ErrConflict = errors.New("conflict")
|
||||||
|
)
|
||||||
@ -14,4 +14,5 @@ type WorkRepository interface {
|
|||||||
FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
||||||
GetWithTranslations(ctx context.Context, id uint) (*Work, error)
|
GetWithTranslations(ctx context.Context, id uint) (*Work, error)
|
||||||
ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[Work], error)
|
||||||
|
IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error)
|
||||||
}
|
}
|
||||||
@ -187,3 +187,12 @@ func ContextWithUserID(ctx context.Context, userID uint) context.Context {
|
|||||||
claims := &Claims{UserID: userID}
|
claims := &Claims{UserID: userID}
|
||||||
return context.WithValue(ctx, ClaimsContextKey, claims)
|
return context.WithValue(ctx, ClaimsContextKey, claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ContextWithAdminUser adds an admin user to the context for testing purposes.
|
||||||
|
func ContextWithAdminUser(ctx context.Context, userID uint) context.Context {
|
||||||
|
claims := &Claims{
|
||||||
|
UserID: userID,
|
||||||
|
Role: "admin",
|
||||||
|
}
|
||||||
|
return context.WithValue(ctx, ClaimsContextKey, claims)
|
||||||
|
}
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
package testutil
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"tercul/internal/domain"
|
|
||||||
"tercul/internal/domain/work"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/mock"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MockWorkRepository is a mock implementation of the WorkRepository interface.
|
|
||||||
type MockWorkRepository struct {
|
|
||||||
mock.Mock
|
|
||||||
Works []*work.Work
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMockWorkRepository creates a new MockWorkRepository.
|
|
||||||
func NewMockWorkRepository() *MockWorkRepository {
|
|
||||||
return &MockWorkRepository{Works: []*work.Work{}}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create adds a new work to the mock repository.
|
|
||||||
func (m *MockWorkRepository) Create(ctx context.Context, work *work.Work) error {
|
|
||||||
work.ID = uint(len(m.Works) + 1)
|
|
||||||
m.Works = append(m.Works, work)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetByID retrieves a work by its ID from the mock repository.
|
|
||||||
func (m *MockWorkRepository) GetByID(ctx context.Context, id uint) (*work.Work, error) {
|
|
||||||
for _, w := range m.Works {
|
|
||||||
if w.ID == id {
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil, gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exists uses the mock's Called method.
|
|
||||||
func (m *MockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) {
|
|
||||||
args := m.Called(ctx, id)
|
|
||||||
return args.Bool(0), args.Error(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The rest of the WorkRepository and BaseRepository methods can be stubbed out.
|
|
||||||
func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) {
|
|
||||||
return m.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
|
|
||||||
return m.Create(ctx, entity)
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, id uint, options *domain.QueryOptions) (*work.Work, error) {
|
|
||||||
return m.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) Update(ctx context.Context, entity *work.Work) error {
|
|
||||||
for i, w := range m.Works {
|
|
||||||
if w.ID == entity.ID {
|
|
||||||
m.Works[i] = entity
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *work.Work) error {
|
|
||||||
return m.Update(ctx, entity)
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) Delete(ctx context.Context, id uint) error {
|
|
||||||
for i, w := range m.Works {
|
|
||||||
if w.ID == id {
|
|
||||||
m.Works = append(m.Works[:i], m.Works[i+1:]...)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return gorm.ErrRecordNotFound
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error {
|
|
||||||
return m.Delete(ctx, id)
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) {
|
|
||||||
var works []work.Work
|
|
||||||
for _, w := range m.Works {
|
|
||||||
works = append(works, *w)
|
|
||||||
}
|
|
||||||
return works, nil
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) Count(ctx context.Context) (int64, error) {
|
|
||||||
return int64(len(m.Works)), nil
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*work.Work, error) {
|
|
||||||
return m.GetByID(ctx, id)
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]work.Work, error) {
|
|
||||||
panic("not implemented")
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) BeginTx(ctx context.Context) (*gorm.DB, error) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
func (m *MockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error {
|
|
||||||
return fn(nil)
|
|
||||||
}
|
|
||||||
@ -2,45 +2,8 @@ package testutil
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
graph "tercul/internal/adapters/graphql"
|
|
||||||
"tercul/internal/app"
|
|
||||||
"tercul/internal/app/localization"
|
|
||||||
"tercul/internal/app/work"
|
|
||||||
"tercul/internal/domain"
|
|
||||||
domain_localization "tercul/internal/domain/localization"
|
|
||||||
domain_work "tercul/internal/domain/work"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/suite"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// SimpleTestSuite provides a minimal test environment with just the essentials
|
|
||||||
type SimpleTestSuite struct {
|
|
||||||
suite.Suite
|
|
||||||
WorkRepo *MockWorkRepository
|
|
||||||
WorkService *work.Service
|
|
||||||
MockSearchClient *MockSearchClient
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockSearchClient is a mock implementation of the search.SearchClient interface.
|
|
||||||
type MockSearchClient struct{}
|
|
||||||
|
|
||||||
// IndexWork is the mock implementation of the IndexWork method.
|
|
||||||
func (m *MockSearchClient) IndexWork(ctx context.Context, work *domain_work.Work, pipeline string) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupSuite sets up the test suite
|
|
||||||
func (s *SimpleTestSuite) SetupSuite() {
|
|
||||||
s.WorkRepo = NewMockWorkRepository()
|
|
||||||
s.MockSearchClient = &MockSearchClient{}
|
|
||||||
s.WorkService = work.NewService(s.WorkRepo, s.MockSearchClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetupTest resets test data for each test
|
|
||||||
func (s *SimpleTestSuite) SetupTest() {
|
|
||||||
s.WorkRepo = NewMockWorkRepository()
|
|
||||||
}
|
|
||||||
|
|
||||||
// MockLocalizationRepository is a mock implementation of the localization repository.
|
// MockLocalizationRepository is a mock implementation of the localization repository.
|
||||||
type MockLocalizationRepository struct{}
|
type MockLocalizationRepository struct{}
|
||||||
|
|
||||||
@ -60,33 +23,3 @@ func (m *MockLocalizationRepository) GetTranslations(ctx context.Context, keys [
|
|||||||
func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
|
func (m *MockLocalizationRepository) GetAuthorBiography(ctx context.Context, authorID uint, language string) (string, error) {
|
||||||
return "This is a mock biography.", nil
|
return "This is a mock biography.", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetResolver returns a minimal GraphQL resolver for testing
|
|
||||||
func (s *SimpleTestSuite) GetResolver() *graph.Resolver {
|
|
||||||
var mockLocalizationRepo domain_localization.LocalizationRepository = &MockLocalizationRepository{}
|
|
||||||
localizationService := localization.NewService(mockLocalizationRepo)
|
|
||||||
|
|
||||||
return &graph.Resolver{
|
|
||||||
App: &app.Application{
|
|
||||||
Work: s.WorkService,
|
|
||||||
Localization: localizationService,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateTestWork creates a test work with optional content
|
|
||||||
func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) *domain_work.Work {
|
|
||||||
work := &domain_work.Work{
|
|
||||||
Title: title,
|
|
||||||
TranslatableModel: domain.TranslatableModel{Language: language},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add work to the mock repository
|
|
||||||
createdWork, err := s.WorkService.Commands.CreateWork(context.Background(), work)
|
|
||||||
s.Require().NoError(err)
|
|
||||||
|
|
||||||
// If content is provided, we'll need to handle it differently
|
|
||||||
// since the mock repository doesn't support translations yet
|
|
||||||
// For now, just return the work
|
|
||||||
return createdWork
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user