diff --git a/TASKS.md b/TASKS.md index 94ed80a..ebec575 100644 --- a/TASKS.md +++ b/TASKS.md @@ -8,7 +8,7 @@ This document is the single source of truth for all outstanding development task ### Stabilize Core Logic (Prevent Panics) -- [x] **Fix Background Job Panic:** The background job queue in `internal/jobs/sync/queue.go` can panic on error. This has been refactored to handle errors gracefully by using `log.Printf` instead of `log.Fatalf`. +- [ ] **Fix Background Job Panic:** The background job queue in `internal/jobs/sync/queue.go` can panic on error. This must be refactored to handle errors gracefully. --- @@ -54,10 +54,10 @@ This document is the single source of truth for all outstanding development task ### EPIC: Robust Testing Framework -- [x] **Refactor Testing Utilities:** Decouple our tests from a live database to make them faster and more reliable. - - [x] Verified that `internal/testutil/testutil.go` is already database-agnostic. -- [x] **Implement Mock Repositories:** The test mocks that were incomplete and causing `panic`s have been implemented. - - [x] Implemented the `panic("not implemented")` methods in `internal/adapters/graphql/like_repo_mock_test.go`, `internal/adapters/graphql/work_repo_mock_test.go`, and `internal/testutil/mock_user_repository.go`. +- [ ] **Refactor Testing Utilities:** Decouple our tests from a live database to make them faster and more reliable. + - [ ] Remove all database connection logic from `internal/testutil/testutil.go`. +- [ ] **Implement Mock Repositories:** The test mocks are incomplete and `panic`. + - [ ] Implement the `panic("not implemented")` methods in `internal/adapters/graphql/like_repo_mock_test.go`, `internal/adapters/graphql/work_repo_mock_test.go`, and `internal/testutil/mock_user_repository.go`. --- @@ -75,7 +75,7 @@ This document is the single source of truth for all outstanding development task ### EPIC: Further Architectural Improvements - [ ] **Refactor Caching:** Replace the bespoke cached repositories with a decorator pattern in `internal/data/cache`. -- [x] **Consolidate Duplicated Structs:** The duplicated `WorkAnalytics` and `TranslationAnalytics` structs have been consolidated into a new `internal/domain/analytics` package. +- [ ] **Consolidate Duplicated Structs:** The `WorkAnalytics` and `TranslationAnalytics` structs are defined in two different packages. Consolidate them. --- diff --git a/go.mod b/go.mod index cf0b7b5..51caf33 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/prometheus/client_golang v1.20.5 github.com/redis/go-redis/v9 v9.13.0 github.com/rs/zerolog v1.34.0 + github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/vektah/gqlparser/v2 v2.5.30 github.com/weaviate/weaviate v1.33.0-rc.1 @@ -47,6 +48,7 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/elastic/go-sysinfo v1.15.4 // indirect github.com/elastic/go-windows v1.0.2 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect @@ -92,6 +94,7 @@ require ( github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/paulmach/orb v0.11.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -101,12 +104,17 @@ require ( github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect 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/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/subosito/gotenv v1.6.0 // 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 @@ -118,6 +126,7 @@ require ( go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.26.0 // indirect golang.org/x/net v0.42.0 // indirect diff --git a/go.sum b/go.sum index 907112b..85f8f0a 100644 --- a/go.sum +++ b/go.sum @@ -94,6 +94,8 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= @@ -315,6 +317,8 @@ github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pemistahl/lingua-go v1.4.0 h1:ifYhthrlW7iO4icdubwlduYnmwU37V1sbNrwhKBR4rM= github.com/pemistahl/lingua-go v1.4.0/go.mod h1:ECuM1Hp/3hvyh7k8aWSqNCPlTxLemFZsRjocUf3KgME= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= @@ -359,6 +363,8 @@ github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -372,10 +378,18 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= -github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= -github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= @@ -387,6 +401,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= @@ -445,6 +461,8 @@ go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190422162423-af44ce270edf/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= diff --git a/internal/adapters/graphql/like_repo_mock_test.go b/internal/adapters/graphql/like_repo_mock_test.go index b5cef25..9c110a5 100644 --- a/internal/adapters/graphql/like_repo_mock_test.go +++ b/internal/adapters/graphql/like_repo_mock_test.go @@ -29,32 +29,16 @@ func (m *mockLikeRepository) Delete(ctx context.Context, id uint) error { return args.Error(0) } func (m *mockLikeRepository) ListByUserID(ctx context.Context, userID uint) ([]domain.Like, error) { - args := m.Called(ctx, userID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]domain.Like), args.Error(1) + panic("not implemented") } func (m *mockLikeRepository) ListByWorkID(ctx context.Context, workID uint) ([]domain.Like, error) { - args := m.Called(ctx, workID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]domain.Like), args.Error(1) + panic("not implemented") } func (m *mockLikeRepository) ListByTranslationID(ctx context.Context, translationID uint) ([]domain.Like, error) { - args := m.Called(ctx, translationID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]domain.Like), args.Error(1) + panic("not implemented") } func (m *mockLikeRepository) ListByCommentID(ctx context.Context, commentID uint) ([]domain.Like, error) { - args := m.Called(ctx, commentID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]domain.Like), args.Error(1) + panic("not implemented") } // Implement the rest of the BaseRepository methods as needed, or panic if they are not expected to be called. @@ -75,47 +59,26 @@ func (m *mockLikeRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uin return m.Delete(ctx, id) } func (m *mockLikeRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.Like], error) { - args := m.Called(ctx, page, pageSize) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.PaginatedResult[domain.Like]), args.Error(1) + panic("not implemented") } func (m *mockLikeRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.Like, error) { - args := m.Called(ctx, options) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]domain.Like), args.Error(1) -} -func (m *mockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { - args := m.Called(ctx) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]domain.Like), args.Error(1) + panic("not implemented") } +func (m *mockLikeRepository) ListAll(ctx context.Context) ([]domain.Like, error) { panic("not implemented") } func (m *mockLikeRepository) Count(ctx context.Context) (int64, error) { - args := m.Called(ctx) - return args.Get(0).(int64), args.Error(1) + panic("not implemented") } 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) + panic("not implemented") } func (m *mockLikeRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.Like, error) { return m.GetByID(ctx, id) } func (m *mockLikeRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.Like, error) { - args := m.Called(ctx, batchSize, offset) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]domain.Like), args.Error(1) + panic("not implemented") } func (m *mockLikeRepository) Exists(ctx context.Context, id uint) (bool, error) { - args := m.Called(ctx, id) - return args.Bool(0), args.Error(1) + panic("not implemented") } func (m *mockLikeRepository) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } func (m *mockLikeRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { diff --git a/internal/adapters/graphql/schema.resolvers.go b/internal/adapters/graphql/schema.resolvers.go index 20945bd..48adac5 100644 --- a/internal/adapters/graphql/schema.resolvers.go +++ b/internal/adapters/graphql/schema.resolvers.go @@ -1219,31 +1219,7 @@ func (r *queryResolver) Books(ctx context.Context, limit *int32, offset *int32) // Author is the resolver for the author field. func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, error) { - authorID, err := strconv.ParseUint(id, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid author ID: %v", err) - } - - authorRecord, err := r.App.Author.Queries.Author(ctx, uint(authorID)) - if err != nil { - return nil, err - } - if authorRecord == nil { - return nil, nil // Or return a "not found" error - } - - var bio *string - biography, err := r.App.Localization.Queries.GetAuthorBiography(ctx, authorRecord.ID, authorRecord.Language) - if err == nil && biography != "" { - bio = &biography - } - - return &model.Author{ - ID: fmt.Sprintf("%d", authorRecord.ID), - Name: authorRecord.Name, - Language: authorRecord.Language, - Biography: bio, - }, nil + panic(fmt.Errorf("not implemented: Author - author")) } // Authors is the resolver for the authors field. @@ -1291,33 +1267,7 @@ func (r *queryResolver) Authors(ctx context.Context, limit *int32, offset *int32 // User is the resolver for the user field. func (r *queryResolver) User(ctx context.Context, id string) (*model.User, error) { - userID, err := strconv.ParseUint(id, 10, 32) - if err != nil { - return nil, fmt.Errorf("invalid user ID: %v", err) - } - - user, err := r.App.User.Queries.User(ctx, uint(userID)) - if err != nil { - return nil, err - } - if user == nil { - return nil, nil // Or return a "not found" error - } - - // Convert to GraphQL model - return &model.User{ - ID: fmt.Sprintf("%d", user.ID), - Username: user.Username, - Email: user.Email, - FirstName: &user.FirstName, - LastName: &user.LastName, - DisplayName: &user.DisplayName, - Bio: &user.Bio, - AvatarURL: &user.AvatarURL, - Role: model.UserRole(user.Role), - Verified: user.Verified, - Active: user.Active, - }, nil + panic(fmt.Errorf("not implemented: User - user")) } // UserByEmail is the resolver for the userByEmail field. @@ -1394,32 +1344,7 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, // Me is the resolver for the me field. func (r *queryResolver) Me(ctx context.Context) (*model.User, error) { - // Get user ID from context - userID, ok := platform_auth.GetUserIDFromContext(ctx) - if !ok { - return nil, domain.ErrUnauthorized - } - - // Fetch user details - user, err := r.App.User.Queries.User(ctx, userID) - if err != nil { - return nil, err - } - - // Convert to GraphQL model - return &model.User{ - ID: fmt.Sprintf("%d", user.ID), - Username: user.Username, - Email: user.Email, - FirstName: &user.FirstName, - LastName: &user.LastName, - DisplayName: &user.DisplayName, - Bio: &user.Bio, - AvatarURL: &user.AvatarURL, - Role: model.UserRole(user.Role), - Verified: user.Verified, - Active: user.Active, - }, nil + panic(fmt.Errorf("not implemented: Me - me")) } // UserProfile is the resolver for the userProfile field. diff --git a/internal/adapters/graphql/work_repo_mock_test.go b/internal/adapters/graphql/work_repo_mock_test.go index 4872ac1..4fdbe07 100644 --- a/internal/adapters/graphql/work_repo_mock_test.go +++ b/internal/adapters/graphql/work_repo_mock_test.go @@ -46,43 +46,24 @@ func (m *mockWorkRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uin return m.Delete(ctx, id) } func (m *mockWorkRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - args := m.Called(ctx, page, pageSize) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.PaginatedResult[work.Work]), args.Error(1) + panic("not implemented") } func (m *mockWorkRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]work.Work, error) { - args := m.Called(ctx, options) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]work.Work), args.Error(1) -} -func (m *mockWorkRepository) ListAll(ctx context.Context) ([]work.Work, error) { - args := m.Called(ctx) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]work.Work), args.Error(1) + 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) { - args := m.Called(ctx, options) - return args.Get(0).(int64), args.Error(1) + 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) { - args := m.Called(ctx, batchSize, offset) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]work.Work), args.Error(1) + panic("not implemented") } func (m *mockWorkRepository) Exists(ctx context.Context, id uint) (bool, error) { args := m.Called(ctx, id) @@ -93,32 +74,16 @@ func (m *mockWorkRepository) WithTx(ctx context.Context, fn func(tx *gorm.DB) er return fn(nil) } func (m *mockWorkRepository) FindByTitle(ctx context.Context, title string) ([]work.Work, error) { - args := m.Called(ctx, title) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]work.Work), args.Error(1) + panic("not implemented") } func (m *mockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]work.Work, error) { - args := m.Called(ctx, authorID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]work.Work), args.Error(1) + panic("not implemented") } func (m *mockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]work.Work, error) { - args := m.Called(ctx, categoryID) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).([]work.Work), args.Error(1) + panic("not implemented") } func (m *mockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - args := m.Called(ctx, language, page, pageSize) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.PaginatedResult[work.Work]), args.Error(1) + panic("not implemented") } func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*work.Work, error) { args := m.Called(ctx, id) @@ -128,11 +93,7 @@ func (m *mockWorkRepository) GetWithTranslations(ctx context.Context, id uint) ( return args.Get(0).(*work.Work), args.Error(1) } func (m *mockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[work.Work], error) { - args := m.Called(ctx, page, pageSize) - if args.Get(0) == nil { - return nil, args.Error(1) - } - return args.Get(0).(*domain.PaginatedResult[work.Work]), args.Error(1) + panic("not implemented") } func (m *mockWorkRepository) IsAuthor(ctx context.Context, workID uint, authorID uint) (bool, error) { args := m.Called(ctx, workID, authorID) diff --git a/internal/app/work/queries.go b/internal/app/work/queries.go index 97feb58..c6df566 100644 --- a/internal/app/work/queries.go +++ b/internal/app/work/queries.go @@ -10,6 +10,28 @@ import ( "go.opentelemetry.io/otel/trace" ) +// WorkAnalytics contains analytics data for a work +type WorkAnalytics struct { + WorkID uint + ViewCount int64 + LikeCount int64 + CommentCount int64 + BookmarkCount int64 + TranslationCount int64 + ReadabilityScore float64 + SentimentScore float64 + TopKeywords []string + PopularTranslations []TranslationAnalytics +} + +// TranslationAnalytics contains analytics data for a translation +type TranslationAnalytics struct { + TranslationID uint + Language string + ViewCount int64 + LikeCount int64 +} + // WorkQueries contains the query handlers for the work aggregate. type WorkQueries struct { repo work.WorkRepository diff --git a/internal/domain/analytics/entity.go b/internal/domain/analytics/entity.go deleted file mode 100644 index 67c4a9a..0000000 --- a/internal/domain/analytics/entity.go +++ /dev/null @@ -1,23 +0,0 @@ -package analytics - -// WorkAnalytics contains analytics data for a work -type WorkAnalytics struct { - WorkID uint - ViewCount int64 - LikeCount int64 - CommentCount int64 - BookmarkCount int64 - TranslationCount int64 - ReadabilityScore float64 - SentimentScore float64 - TopKeywords []string - PopularTranslations []TranslationAnalytics -} - -// TranslationAnalytics contains analytics data for a translation -type TranslationAnalytics struct { - TranslationID uint - Language string - ViewCount int64 - LikeCount int64 -} \ No newline at end of file diff --git a/internal/jobs/linguistics/work_analysis_service.go b/internal/jobs/linguistics/work_analysis_service.go index a057e34..819c33d 100644 --- a/internal/jobs/linguistics/work_analysis_service.go +++ b/internal/jobs/linguistics/work_analysis_service.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "tercul/internal/domain" - "tercul/internal/domain/analytics" "time" "tercul/internal/platform/log" @@ -16,7 +15,29 @@ type WorkAnalysisService interface { AnalyzeWork(ctx context.Context, workID uint) error // GetWorkAnalytics retrieves analytics data for a work - GetWorkAnalytics(ctx context.Context, workID uint) (*analytics.WorkAnalytics, error) + GetWorkAnalytics(ctx context.Context, workID uint) (*WorkAnalytics, error) +} + +// WorkAnalytics contains analytics data for a work +type WorkAnalytics struct { + WorkID uint + ViewCount int64 + LikeCount int64 + CommentCount int64 + BookmarkCount int64 + TranslationCount int64 + ReadabilityScore float64 + SentimentScore float64 + TopKeywords []string + PopularTranslations []TranslationAnalytics +} + +// TranslationAnalytics contains analytics data for a translation +type TranslationAnalytics struct { + TranslationID uint + Language string + ViewCount int64 + LikeCount int64 } // workAnalysisService implements the WorkAnalysisService interface @@ -129,7 +150,7 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro } // GetWorkAnalytics retrieves analytics data for a work -func (s *workAnalysisService) GetWorkAnalytics(ctx context.Context, workID uint) (*analytics.WorkAnalytics, error) { +func (s *workAnalysisService) GetWorkAnalytics(ctx context.Context, workID uint) (*WorkAnalytics, error) { if workID == 0 { return nil, fmt.Errorf("invalid work ID") } @@ -158,7 +179,7 @@ func (s *workAnalysisService) GetWorkAnalytics(ctx context.Context, workID uint) } // For now, return placeholder analytics with actual analysis data - return &analytics.WorkAnalytics{ + return &WorkAnalytics{ WorkID: work.ID, ViewCount: 0, // TODO: Implement view counting LikeCount: 0, // TODO: Implement like counting @@ -168,7 +189,7 @@ func (s *workAnalysisService) GetWorkAnalytics(ctx context.Context, workID uint) ReadabilityScore: readabilityScore.Score, SentimentScore: extractSentimentFromAnalysis(languageAnalysis.Analysis), TopKeywords: keywords, - PopularTranslations: []analytics.TranslationAnalytics{}, // TODO: Implement translation analytics + PopularTranslations: []TranslationAnalytics{}, // TODO: Implement translation analytics }, nil } @@ -181,4 +202,4 @@ func extractSentimentFromAnalysis(analysis domain.JSONB) float64 { return sentiment } return 0.0 -} \ No newline at end of file +} diff --git a/internal/jobs/sync/queue.go b/internal/jobs/sync/queue.go index d010709..361d5df 100644 --- a/internal/jobs/sync/queue.go +++ b/internal/jobs/sync/queue.go @@ -64,6 +64,6 @@ func RegisterQueueHandlers(srv *asynq.Server, syncJob *SyncJob) { mux.HandleFunc(TaskEntitySync, syncJob.HandleEntitySync) mux.HandleFunc(TaskEdgeSync, syncJob.HandleEdgeSync) if err := srv.Run(mux); err != nil { - log.Printf("Failed to start asynq server: %v", err) + log.Fatalf("Failed to start asynq server: %v", err) } } diff --git a/internal/platform/cache/redis_cache.go b/internal/platform/cache/redis_cache.go index b00d033..69c66bb 100644 --- a/internal/platform/cache/redis_cache.go +++ b/internal/platform/cache/redis_cache.go @@ -5,11 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "tercul/internal/platform/config" + "tercul/internal/platform/log" "time" "github.com/redis/go-redis/v9" - "tercul/internal/platform/config" - "tercul/internal/platform/log" ) // RedisCache implements the Cache interface using Redis @@ -37,12 +37,12 @@ func NewRedisCache(client *redis.Client, keyGenerator KeyGenerator, defaultExpir } // NewDefaultRedisCache creates a new RedisCache with default settings -func NewDefaultRedisCache() (*RedisCache, error) { +func NewDefaultRedisCache(cfg *config.Config) (*RedisCache, error) { // Create Redis client from config client := redis.NewClient(&redis.Options{ - Addr: config.Cfg.RedisAddr, - Password: config.Cfg.RedisPassword, - DB: config.Cfg.RedisDB, + Addr: cfg.RedisAddr, + Password: cfg.RedisPassword, + DB: cfg.RedisDB, }) // Test connection @@ -208,4 +208,4 @@ func (c *RedisCache) InvalidateEntityType(ctx context.Context, entityType string } return iter.Err() -} +} \ No newline at end of file diff --git a/internal/platform/config/config.go b/internal/platform/config/config.go index dd7a8c4..9367df2 100644 --- a/internal/platform/config/config.go +++ b/internal/platform/config/config.go @@ -1,159 +1,57 @@ package config import ( - "fmt" - "log" - "os" - "strconv" - "time" + "github.com/spf13/viper" ) -// Config holds all configuration for the application +// Config stores all configuration of the application. type Config struct { - // Database configuration - DBHost string - DBPort string - DBUser string - DBPassword string - DBName string - DBSSLMode string - DBTimeZone string - - // Weaviate configuration - WeaviateScheme string - WeaviateHost string - - // Redis configuration - RedisAddr string - RedisPassword string - RedisDB int - - // Application configuration - Port string - ServerPort string - Environment string - LogLevel string - - // Performance configuration - BatchSize int - PageSize int - RetryInterval time.Duration - MaxRetries int - - // Security configuration - RateLimit int // Requests per second - RateLimitBurst int // Maximum burst size - JWTSecret string - JWTExpiration time.Duration - - // NLP providers configuration - NLPUseLingua bool - NLPUseVADER bool - NLPUseTFIDF bool - - // NLP cache configuration - NLPMemoryCacheCap int - NLPRedisCacheTTLSeconds int + Environment string `mapstructure:"ENVIRONMENT"` + ServerPort string `mapstructure:"SERVER_PORT"` + DBHost string `mapstructure:"DB_HOST"` + DBPort string `mapstructure:"DB_PORT"` + DBUser string `mapstructure:"DB_USER"` + DBPassword string `mapstructure:"DB_PASSWORD"` + DBName string `mapstructure:"DB_NAME"` + JWTSecret string `mapstructure:"JWT_SECRET"` + JWTExpiration int `mapstructure:"JWT_EXPIRATION_HOURS"` + WeaviateHost string `mapstructure:"WEAVIATE_HOST"` + WeaviateScheme string `mapstructure:"WEAVIATE_SCHEME"` + MigrationPath string `mapstructure:"MIGRATION_PATH"` + RedisAddr string `mapstructure:"REDIS_ADDR"` + RedisPassword string `mapstructure:"REDIS_PASSWORD"` + RedisDB int `mapstructure:"REDIS_DB"` + SyncBatchSize int `mapstructure:"SYNC_BATCH_SIZE"` + RateLimit int `mapstructure:"RATE_LIMIT"` + RateLimitBurst int `mapstructure:"RATE_LIMIT_BURST"` } -// Cfg is the global configuration instance -var Cfg Config +// LoadConfig reads configuration from file or environment variables. +func LoadConfig() (*Config, error) { + viper.SetDefault("ENVIRONMENT", "development") + viper.SetDefault("SERVER_PORT", ":8080") + viper.SetDefault("DB_HOST", "localhost") + viper.SetDefault("DB_PORT", "5432") + viper.SetDefault("DB_USER", "user") + viper.SetDefault("DB_PASSWORD", "password") + viper.SetDefault("DB_NAME", "tercul") + viper.SetDefault("JWT_SECRET", "secret") + viper.SetDefault("JWT_EXPIRATION_HOURS", 24) + viper.SetDefault("WEAVIATE_HOST", "localhost:8080") + viper.SetDefault("WEAVIATE_SCHEME", "http") + viper.SetDefault("MIGRATION_PATH", "internal/data/migrations") + viper.SetDefault("REDIS_ADDR", "localhost:6379") + viper.SetDefault("REDIS_PASSWORD", "") + viper.SetDefault("REDIS_DB", 0) + viper.SetDefault("SYNC_BATCH_SIZE", 100) + viper.SetDefault("RATE_LIMIT", 10) + viper.SetDefault("RATE_LIMIT_BURST", 100) -// LoadConfig loads configuration from environment variables -func LoadConfig() { - Cfg = Config{ - // Database configuration - DBHost: getEnv("DB_HOST", "localhost"), - DBPort: getEnv("DB_PORT", "5432"), - DBUser: getEnv("DB_USER", "postgres"), - DBPassword: getEnv("DB_PASSWORD", "postgres"), - DBName: getEnv("DB_NAME", "tercul"), - DBSSLMode: getEnv("DB_SSLMODE", "disable"), - DBTimeZone: getEnv("DB_TIMEZONE", "UTC"), + viper.AutomaticEnv() - // Weaviate configuration - WeaviateScheme: getEnv("WEAVIATE_SCHEME", "http"), - WeaviateHost: getEnv("WEAVIATE_HOST", "localhost:8080"), - - // Redis configuration - RedisAddr: getEnv("REDIS_ADDR", "127.0.0.1:6379"), - RedisPassword: getEnv("REDIS_PASSWORD", ""), - RedisDB: getEnvAsInt("REDIS_DB", 0), - - // Application configuration - Port: getEnv("PORT", "8080"), - ServerPort: getEnv("SERVER_PORT", "8080"), - Environment: getEnv("ENVIRONMENT", "development"), - LogLevel: getEnv("LOG_LEVEL", "info"), - - // Performance configuration - BatchSize: getEnvAsInt("BATCH_SIZE", 100), - PageSize: getEnvAsInt("PAGE_SIZE", 20), - RetryInterval: time.Duration(getEnvAsInt("RETRY_INTERVAL_SECONDS", 2)) * time.Second, - MaxRetries: getEnvAsInt("MAX_RETRIES", 3), - - // Security configuration - RateLimit: getEnvAsInt("RATE_LIMIT", 10), // 10 requests per second by default - RateLimitBurst: getEnvAsInt("RATE_LIMIT_BURST", 50), // 50 burst requests by default - JWTSecret: getEnv("JWT_SECRET", ""), - JWTExpiration: time.Duration(getEnvAsInt("JWT_EXPIRATION_HOURS", 24)) * time.Hour, - - // NLP providers configuration (enabled by default) - NLPUseLingua: getEnvAsBool("NLP_USE_LINGUA", true), - NLPUseVADER: getEnvAsBool("NLP_USE_VADER", true), - NLPUseTFIDF: getEnvAsBool("NLP_USE_TFIDF", true), - - // NLP cache configuration - NLPMemoryCacheCap: getEnvAsInt("NLP_MEMORY_CACHE_CAP", 1024), - NLPRedisCacheTTLSeconds: getEnvAsInt("NLP_REDIS_CACHE_TTL_SECONDS", 86400), + var config Config + if err := viper.Unmarshal(&config); err != nil { + return nil, err } - - log.Printf("Configuration loaded: Environment=%s, LogLevel=%s", Cfg.Environment, Cfg.LogLevel) -} - -// GetDSN returns the database connection string -func (c *Config) GetDSN() string { - return fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s TimeZone=%s", - c.DBHost, c.DBPort, c.DBUser, c.DBPassword, c.DBName, c.DBSSLMode, c.DBTimeZone) -} - -// Helper functions for environment variables - -// getEnv gets an environment variable or returns a default value -func getEnv(key, defaultValue string) string { - value, exists := os.LookupEnv(key) - if !exists { - return defaultValue - } - return value -} - -// getEnvAsInt gets an environment variable as an integer or returns a default value -func getEnvAsInt(key string, defaultValue int) int { - valueStr := getEnv(key, "") - if valueStr == "" { - return defaultValue - } - value, err := strconv.Atoi(valueStr) - if err != nil { - log.Printf("Warning: Invalid value for %s, using default: %v", key, err) - return defaultValue - } - return value -} - -// getEnvAsBool gets an environment variable as a boolean or returns a default value -func getEnvAsBool(key string, defaultValue bool) bool { - valueStr := getEnv(key, "") - if valueStr == "" { - return defaultValue - } - switch valueStr { - case "1", "true", "TRUE", "True", "yes", "YES", "Yes", "on", "ON", "On": - return true - case "0", "false", "FALSE", "False", "no", "NO", "No", "off", "OFF", "Off": - return false - default: - return defaultValue - } -} + return &config, nil +} \ No newline at end of file diff --git a/internal/platform/db/db.go b/internal/platform/db/db.go index 98ea014..8132ead 100644 --- a/internal/platform/db/db.go +++ b/internal/platform/db/db.go @@ -3,24 +3,23 @@ package db import ( "fmt" "tercul/internal/observability" + "tercul/internal/platform/config" + "tercul/internal/platform/log" "time" "gorm.io/driver/postgres" "gorm.io/gorm" gormlogger "gorm.io/gorm/logger" - "tercul/internal/platform/config" - "tercul/internal/platform/log" ) -// DB is a global database connection instance -var DB *gorm.DB +// Connect establishes a connection to the database using the provided configuration. +// It returns the database connection and any error encountered. +func Connect(cfg *config.Config, metrics *observability.Metrics) (*gorm.DB, error) { + log.Info(fmt.Sprintf("Connecting to database: host=%s db=%s", cfg.DBHost, cfg.DBName)) -// Connect establishes a connection to the database using configuration settings -// It returns the database connection and any error encountered -func Connect(metrics *observability.Metrics) (*gorm.DB, error) { - log.Info(fmt.Sprintf("Connecting to database: host=%s db=%s", config.Cfg.DBHost, config.Cfg.DBName)) + dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable", + cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort) - dsn := config.Cfg.GetDSN() db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ Logger: gormlogger.Default.LogMode(gormlogger.Warn), }) @@ -33,9 +32,6 @@ func Connect(metrics *observability.Metrics) (*gorm.DB, error) { return nil, fmt.Errorf("failed to register prometheus plugin: %w", err) } - // Set the global DB instance - DB = db - // Get the underlying SQL DB instance sqlDB, err := db.DB() if err != nil { @@ -47,18 +43,18 @@ func Connect(metrics *observability.Metrics) (*gorm.DB, error) { sqlDB.SetMaxIdleConns(5) // Idle connections sqlDB.SetConnMaxLifetime(30 * time.Minute) - log.Info(fmt.Sprintf("Successfully connected to database: host=%s db=%s", config.Cfg.DBHost, config.Cfg.DBName)) + log.Info(fmt.Sprintf("Successfully connected to database: host=%s db=%s", cfg.DBHost, cfg.DBName)) return db, nil } -// Close closes the database connection -func Close() error { - if DB == nil { +// Close closes the database connection. +func Close(db *gorm.DB) error { + if db == nil { return nil } - sqlDB, err := DB.DB() + sqlDB, err := db.DB() if err != nil { return fmt.Errorf("failed to get SQL DB instance: %w", err) } @@ -66,16 +62,12 @@ func Close() error { return sqlDB.Close() } -// InitDB initializes the database connection and runs migrations -// It returns the database connection and any error encountered -func InitDB(metrics *observability.Metrics) (*gorm.DB, error) { +// InitDB initializes the database connection. +func InitDB(cfg *config.Config, metrics *observability.Metrics) (*gorm.DB, error) { // Connect to the database - db, err := Connect(metrics) + db, err := Connect(cfg, metrics) if err != nil { return nil, err } - - // Migrations are now handled by a separate tool - return db, nil -} +} \ No newline at end of file diff --git a/internal/testutil/mock_user_repository.go b/internal/testutil/mock_user_repository.go index e4f9bd5..0684bba 100644 --- a/internal/testutil/mock_user_repository.go +++ b/internal/testutil/mock_user_repository.go @@ -98,62 +98,29 @@ func (m *MockUserRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, id uin return m.Delete(ctx, id) } func (m *MockUserRepository) List(ctx context.Context, page, pageSize int) (*domain.PaginatedResult[domain.User], error) { - start := (page - 1) * pageSize - end := start + pageSize - if start > len(m.Users) { - start = len(m.Users) - } - if end > len(m.Users) { - end = len(m.Users) - } - users := m.Users[start:end] - var resultUsers []domain.User - for _, u := range users { - resultUsers = append(resultUsers, *u) - } - return &domain.PaginatedResult[domain.User]{ - Items: resultUsers, - TotalCount: int64(len(m.Users)), - Page: page, - PageSize: pageSize, - }, nil + panic("not implemented") } func (m *MockUserRepository) ListWithOptions(ctx context.Context, options *domain.QueryOptions) ([]domain.User, error) { - // This is a mock implementation, so we'll just return all users for now. - return m.ListAll(ctx) + panic("not implemented") } func (m *MockUserRepository) ListAll(ctx context.Context) ([]domain.User, error) { var users []domain.User - for _, u := range m.Users { - users = append(users, *u) - } - return users, nil + for _, u := range m.Users { + users = append(users, *u) + } + return users, nil } func (m *MockUserRepository) Count(ctx context.Context) (int64, error) { return int64(len(m.Users)), nil } func (m *MockUserRepository) CountWithOptions(ctx context.Context, options *domain.QueryOptions) (int64, error) { - // This is a mock implementation, so we'll just return the total count for now. - return m.Count(ctx) + panic("not implemented") } func (m *MockUserRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*domain.User, error) { return m.GetByID(ctx, id) } func (m *MockUserRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]domain.User, error) { - start := offset - end := start + batchSize - if start > len(m.Users) { - return []domain.User{}, nil - } - if end > len(m.Users) { - end = len(m.Users) - } - users := m.Users[start:end] - var resultUsers []domain.User - for _, u := range users { - resultUsers = append(resultUsers, *u) - } - return resultUsers, nil + panic("not implemented") } func (m *MockUserRepository) Exists(ctx context.Context, id uint) (bool, error) { _, err := m.GetByID(ctx, id)