diff --git a/.gitignore b/.gitignore index b30cdfd..5c7dbe3 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,8 @@ Temporary Items *.db *.sqlite *.sqlite3 +*.db-shm +*.db-wal tercul_data.db tercul_export.json extracted_data.json @@ -118,6 +120,7 @@ data_analysis_report.json tercul_data.sql tercul_schema.sql current_schema.sql +full_database_backup.sql # Migration data migration_data/ @@ -173,3 +176,8 @@ yarn-error.log* # Generated files graph/generated.go graph/model/models_gen.go + +# Additional files that should be ignored +refactor.md +report.md +requirements.txt \ No newline at end of file diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..1b4a789 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.25.0 diff --git a/TODO.md b/TODO.md index e50313c..4cc5ca4 100644 --- a/TODO.md +++ b/TODO.md @@ -2,131 +2,53 @@ --- -## [x] Performance Improvements +## [ ] Performance Improvements -- [x] **COMPLETED: Add pagination to all repository list operations** (High, 2d) - - [x] /works: Add limit/offset support to repository and resolver - - [x] /translations: Add limit/offset support to repository and resolver - - [x] /authors: Add limit/offset support to repository and resolver - - [x] /users: Add limit/offset support to repository and resolver - - [x] /collections: Add limit/offset support to repository and resolver - - [x] /tags: Add limit/offset support to repository and resolver - - [x] /categories: Add limit/offset support to repository and resolver - - [x] /comments: Add limit/offset support to repository and resolver - - [x] /search: Add limit/offset support to repository and resolver - - [x] Validate all endpoints for correct pagination and total count - - [x] Add unit tests for paginated list operations - - [x] Document pagination parameters in API docs -- [x] **COMPLETED: Refactor raw SQL queries to use GORM structured methods** (High, 1d) - - [x] Identify all usages of raw SQL queries in repositories and sync jobs - - [x] Refactor syncEntities in syncjob/entities_sync.go to use GORM methods - - [x] Refactor any string-concatenated queries to parameterized GORM queries - - [x] Validate correctness and performance of refactored queries - - [x] Add unit tests for refactored query logic - - [x] Document query changes and migration steps - [ ] Implement batching for Weaviate operations (Medium, 2d) -- [x] **COMPLETED: Optimize linguistic analysis algorithms** (Medium, 2d) - - [x] Introduced clean NLP ports/adapters (`LanguageDetector`, `SentimentProvider`, `KeywordProvider`) - - [x] Integrated lingua-go (language detection) and GoVADER (sentiment) behind adapters - - [x] Added TF-IDF-based keyword provider (lightweight, state-free) - - [x] Bounded in-memory cache via LRU with config-driven capacity - - [x] Switched text cache keys to SHA-256 content hashes - - [x] Concurrent analysis: provider-aware and context-cancellable - - [x] Config toggles for providers and cache TTL -- [x] **COMPLETED: Add database indexes for frequently queried fields** (Medium, 1d) - - [x] Foreign key indexes for all relationships - - [x] Unique indexes for constraint enforcement - - [x] Timestamp indexes for sorting and filtering - - [x] Composite indexes for complex queries - - [x] Linguistic analysis indexes for performance -- [x] **COMPLETED: Implement Redis caching for hot data** (Medium, 2d) -## [x] Security Enhancements +## [ ] Security Enhancements -- [x] **COMPLETED: Implement password hashing in User model** (Critical, 1d) - - [x] bcrypt password hashing in BeforeSave hook - - [x] CheckPassword method for password verification - - [x] Automatic password hashing on model save -- [x] **COMPLETED: Move hardcoded credentials to environment variables/config** (Critical, 1d) - - [x] Fixed internal/cmd/enrich/main.go to use config package - - [x] Fixed internal/testutil/testutil.go to use config package - - [x] All database connections now use environment variables - [ ] Add comprehensive input validation for all GraphQL mutations (High, 2d) -- [x] **COMPLETED: Implement rate limiting for API and background jobs** (High, 2d) - - [x] Rate limiting middleware implemented - - [x] Configuration for rate limits in config package -- [x] **COMPLETED: Replace raw SQL with safe query builders to prevent SQL injection** (Critical, 1d) - - [x] All repositories use GORM structured methods - - [x] No raw SQL queries in production code ## [ ] Code Quality & Architecture -- [x] Remove duplicate GraphQL folder and legacy server helper; keep single GraphQL layer under `graph/` for now -- [x] **REFACTORED: Split linguistics/analyzer.go into focused components** (Completed) -- [x] **COMPLETED: Clean NLP infrastructure and factory wiring** - - [x] Ports for NLP capabilities with SRP/DRY boundaries - - [x] Adapters for lingua-go and GoVADER with fallbacks - - [x] Factory respects config toggles and wires providers - - [x] Repository no longer leaks GORM into services; added methods for fetching work and analysis data - - [x] Created `linguistics/text_analyzer.go` - Pure text analysis logic - - [x] Created `linguistics/analysis_cache.go` - Caching logic with multiple strategies - - [x] Created `linguistics/analysis_repository.go` - Database operations - - [x] Created `linguistics/work_analysis_service.go` - Work-specific analysis coordination - - [x] Created `linguistics/types.go` - Shared data structures - - [x] Created `linguistics/text_utils.go` - Text processing utilities - - [x] Created `linguistics/factory.go` - Component factory with dependency injection -- [x] **REFACTORED: Split main.go into focused components** (Completed) - - [x] Created `internal/app/application_builder.go` - Application initialization - - [x] Created `internal/app/server_factory.go` - Server creation and configuration - - [x] Refactored `main.go` to use dependency injection and builders -- [x] **REFACTORED: Standardize repository implementation** (Completed) - - [x] Improved BaseRepository with comprehensive error handling, validation, logging, and transaction support - - [x] Removed GenericRepository wrapper (unnecessary duplication) - - [x] Updated CachedRepository to use BaseRepository interface - - [x] Refactored WorkRepository and UserRepository to use BaseRepository pattern - - [x] Updated WorkService to use context in all repository calls - - [x] Fixed GraphQL resolvers to use context for WorkRepository calls - - [x] **REFACTORED: All repositories completed!** (Author, Tag, Category, Translation, Comment, Like, Bookmark, Collection, Book, Publisher, Country, Place, City, Source, Edition, UserProfile, UserSession, EmailVerification, PasswordReset, Contribution, Copyright, CopyrightClaim, Monetization, Edge) - - [x] **COMPLETED: Updated mock repositories for testing** - - [x] **COMPLETED: Updated services to use context in repository calls** - - [x] **COMPLETED: Updated GraphQL resolvers to use context and handle pagination** - - [x] **COMPLETED: Fixed linguistics package model field mismatches** - - [x] **COMPLETED: Fixed application builder CopyrightRepository initialization** - - [x] **COMPLETED: Fixed server factory configuration and interface issues** - - [x] **COMPLETED: Removed all legacy code and interfaces** - - [x] **COMPLETED: Project builds successfully!** -- [x] **COMPLETED: Add a service layer for business logic and validation** (High, 2d) - - [x] Comprehensive validation in all service methods - - [x] Business logic separation from repositories - - [x] Input validation for all service operations -- [x] Refactor duplicate code in sync jobs (Medium, 1d) -- [x] **COMPLETED: Improve error handling with custom error types and propagation** (High, 2d) - - [x] Custom error types defined in BaseRepository - - [x] Error wrapping and propagation throughout codebase - - [x] Standardized error handling patterns - [ ] Expand Weaviate client to support all models (Medium, 2d) - [ ] Add code documentation and API docs (Medium, 2d) +## [ ] Architecture Refactor (DDD-lite) + +- [ ] Create skeleton packages: `cmd/`, `internal/platform/`, `internal/domain/`, `internal/app/`, `internal/data/`, `internal/adapters/graphql/`, `internal/jobs/` +- [x] Move infra to `internal/platform/*` (`config`, `db`, `cache`, `auth`, `http`, `log`, `search`) +- [ ] Wire DI in `cmd/api/main.go` and expose an `Application` facade to adapters +- [ ] Unify GraphQL under `internal/adapters/graphql` and update `gqlgen.yml`; move `schema.graphqls` and resolvers +- [ ] Resolvers call application services only; add dataloaders per aggregate +- [ ] Introduce Unit-of-Work: `platform/db.WithTx(ctx, func(ctx) error)` and repo factory for `*sql.DB` / `*sql.Tx` +- [ ] Split write vs read paths for `work` (commands.go, queries.go); make read models cacheable +- [ ] Replace bespoke cached repositories with decorators in `internal/data/cache` (reads only; deterministic invalidation) +- [ ] Restructure `models/*` into domain aggregates with constructors and invariants +- [ ] Adopt migrations tool (goose/atlas/migrate); move SQL to `internal/data/migrations`; delete `migrations.go` +- [ ] Observability: centralize logging; add Prometheus metrics and OpenTelemetry tracing; request IDs +- [ ] Config: replace ad-hoc config with env parsing + validation (e.g., koanf/envconfig); no globals +- [ ] Security: move JWT/middleware to `internal/platform/auth`; add authz policy helpers (e.g., `CanEditWork`) +- [ ] Search: move Weaviate client/schema to `internal/platform/search`, optional domain interface +- [ ] Background jobs: move to `cmd/worker` and `internal/jobs/*`; ensure idempotency and lease +- [ ] Python ops: move scripts to `/ops/migration` and `/ops/analysis`; keep outputs under `/ops/migration/outputs/` +- [ ] Cleanup: delete dead packages (`store`, duplicate `repositories`); consolidate to `internal/data/sql` +- [ ] CI: add `make lint test test-integration` and integration tests with Docker compose + ## [ ] Testing - [ ] Add unit tests for all models, repositories, and services (High, 3d) - [ ] Add integration tests for GraphQL API and background jobs (High, 3d) - [ ] Add performance benchmarks for critical paths (Medium, 2d) - - [x] Added unit tests for linguistics adapters (lingua-go, GoVADER) and utilities - [ ] Add benchmarks for text analysis (sequential vs concurrent) and cache hit/miss rates -## [x] Monitoring & Logging +## [ ] Monitoring & Logging -- [x] **COMPLETED: Integrate a structured logging framework** (Medium, 1d) - - [x] Structured logging implemented throughout codebase - - [x] Performance timing and debug logging in repositories - - [x] Error logging with context and structured fields - [ ] Add monitoring for background jobs and API endpoints (Medium, 2d) - [ ] Add metrics for linguistics: analysis duration, cache hit/miss, provider usage ---- - -## Next Objective Proposal +## [ ] Next Objective Proposal - [ ] Stabilize non-linguistics tests and interfaces (High, 2d) - [ ] Fix `graph` mocks to accept context in service interfaces @@ -139,31 +61,6 @@ - [ ] Document NLP provider toggles and defaults in README/config docs - [ ] Describe SRP/DRY design and extension points for new providers -## [x] Security & Auth - -- [x] **COMPLETED: Implement JWT authentication and role-based authorization** (High, 2d) - - [x] JWT token generation and validation with proper error handling - - [x] Role-based authorization with hierarchy (reader < contributor < reviewer < editor < admin) - - [x] Authentication middleware for GraphQL and HTTP with context validation - - [x] Login and registration mutations with comprehensive input validation - - [x] Password hashing with bcrypt (already implemented in User model) - - [x] Environment variable configuration for JWT with secure defaults - - [x] Comprehensive authentication service following SRP and clean code principles - - [x] Structured logging with proper error context and performance timing - - [x] Input sanitization and validation using govalidator - - [x] Context validation and proper error propagation - - [x] Integration with existing rate limiting system - - [x] GraphQL schema alignment with Go models - - [x] Comprehensive test coverage for authentication components - - [x] Production-ready error handling and security practices -- [x] **COMPLETED: Add rate limiting middleware** (High, 1d) - - [x] Rate limiting middleware implemented and tested - - [x] Configuration-driven rate limits -- [x] **COMPLETED: Use environment variables for all sensitive config** (Critical, 1d) - - [x] All database credentials use environment variables - - [x] Redis configuration uses environment variables - - [x] Centralized configuration management - --- > TODO items include context, priority, and estimated effort. Update this list after each milestone. diff --git a/go.mod b/go.mod index 6eee6a7..1d8c516 100644 --- a/go.mod +++ b/go.mod @@ -17,9 +17,10 @@ require ( github.com/vektah/gqlparser/v2 v2.5.26 github.com/weaviate/weaviate v1.30.2 github.com/weaviate/weaviate-go-client/v5 v5.1.0 + go.uber.org/zap v1.27.0 golang.org/x/crypto v0.37.0 gorm.io/driver/postgres v1.5.11 - gorm.io/gorm v1.26.0 + gorm.io/gorm v1.30.0 ) require ( @@ -49,6 +50,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/opentracing/opentracing-go v1.2.0 // indirect @@ -63,6 +65,7 @@ require ( github.com/urfave/cli/v2 v2.27.6 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect go.mongodb.org/mongo-driver v1.14.0 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/exp v0.0.0-20240808152545-0cdaa3abc0fa // indirect golang.org/x/mod v0.24.0 // indirect golang.org/x/net v0.39.0 // indirect @@ -78,4 +81,5 @@ require ( google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gorm.io/driver/sqlite v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index efe2165..e2e642d 100644 --- a/go.sum +++ b/go.sum @@ -163,6 +163,8 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/markbates/oncer v0.0.0-20181203154359-bf2de49a0be2/go.mod h1:Ld9puTsIW75CHf65OeIOkyKbteujpZVXDpWK6YGZbxE= github.com/markbates/safe v1.0.1/go.mod h1:nAqgmRi7cY2nqMc92/bSEeQA+R4OheNU2T1kNSCBdG0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mitchellh/mapstructure v1.3.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= @@ -252,6 +254,10 @@ go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qq go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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= @@ -346,6 +352,10 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.26.0 h1:9lqQVPG5aNNS6AyHdRiwScAVnXHg/L/Srzx55G5fOgs= gorm.io/gorm v1.26.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= +gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/graph/integration_test.go b/graph/integration_test.go index 1a35959..a09aa68 100644 --- a/graph/integration_test.go +++ b/graph/integration_test.go @@ -2,33 +2,20 @@ package graph_test import ( "bytes" + "context" "encoding/json" "fmt" "net/http" "net/http/httptest" "testing" - "context" "tercul/graph" "tercul/internal/testutil" - "tercul/models" - "tercul/services" "github.com/99designs/gqlgen/graphql/handler" "github.com/stretchr/testify/suite" ) -// MockLocalizationService provides mock localization for tests -type MockLocalizationService struct{} - -func (m *MockLocalizationService) GetWorkContent(ctx context.Context, workID uint, preferredLanguage string) (string, error) { - return "Test content", nil -} - -func (m *MockLocalizationService) GetAuthorBiography(ctx context.Context, authorID uint, preferredLanguage string) (string, error) { - return "Test biography", nil -} - // GraphQLRequest represents a GraphQL request type GraphQLRequest struct { Query string `json:"query"` @@ -44,25 +31,18 @@ type GraphQLResponse struct { // GraphQLIntegrationSuite is a test suite for GraphQL integration tests type GraphQLIntegrationSuite struct { - testutil.BaseSuite - server *httptest.Server - client *http.Client - workRepo *testutil.UnifiedMockWorkRepository // direct access to mock repo + testutil.SimpleTestSuite + server *httptest.Server + client *http.Client } // SetupSuite sets up the test suite func (s *GraphQLIntegrationSuite) SetupSuite() { - // Use in-memory/mock repositories and services - workRepo := &testutil.UnifiedMockWorkRepository{} - workService := services.NewWorkService(workRepo, nil) - mockLocalization := &MockLocalizationService{} - - resolver := &graph.Resolver{ - WorkRepo: workRepo, - WorkService: workService, - Localization: mockLocalization, - } + // Use the simple test utilities + s.SimpleTestSuite.SetupSuite() + // Create GraphQL server with the test resolver + resolver := s.GetResolver() srv := handler.NewDefaultServer(graph.NewExecutableSchema(graph.Config{Resolvers: resolver})) s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -70,28 +50,16 @@ func (s *GraphQLIntegrationSuite) SetupSuite() { })) s.client = s.server.Client() - s.workRepo = workRepo } // TearDownSuite tears down the test suite func (s *GraphQLIntegrationSuite) TearDownSuite() { s.server.Close() - s.BaseSuite.TearDownSuite() } // SetupTest sets up each test func (s *GraphQLIntegrationSuite) SetupTest() { - s.workRepo.Reset() -} - -// createTestWork creates a test work -func (s *GraphQLIntegrationSuite) createTestWork(title, language string) *models.Work { - work := &models.Work{ - Title: title, - } - work.Language = language // set via embedded TranslatableModel - s.workRepo.AddWork(work) - return work + s.SimpleTestSuite.SetupTest() } // executeGraphQL executes a GraphQL query @@ -134,8 +102,8 @@ func (s *GraphQLIntegrationSuite) executeGraphQL(query string, variables map[str // TestQueryWork tests the work query func (s *GraphQLIntegrationSuite) TestQueryWork() { - // Create a test work - work := s.createTestWork("Test Work", "en") + // Create a test work with content + work := s.CreateTestWork("Test Work", "en", "Test content for work") // Define the query query := ` @@ -151,7 +119,7 @@ func (s *GraphQLIntegrationSuite) TestQueryWork() { // Define the variables variables := map[string]interface{}{ - "id": work.ID, + "id": fmt.Sprintf("%d", work.ID), } // Execute the query @@ -165,16 +133,16 @@ func (s *GraphQLIntegrationSuite) TestQueryWork() { workData, ok := response.Data["work"].(map[string]interface{}) s.Require().True(ok, "GraphQL response should contain work data") s.Equal("Test Work", workData["name"], "Work name should match") - s.Equal("Test content", workData["content"], "Work content should match via localization") + s.Equal("Test content for work", workData["content"], "Work content should match") s.Equal("en", workData["language"], "Work language should match") } // TestQueryWorks tests the works query func (s *GraphQLIntegrationSuite) TestQueryWorks() { // Create test works - work1 := s.createTestWork("Test Work 1", "en") - work2 := s.createTestWork("Test Work 2", "en") - work3 := s.createTestWork("Test Work 3", "fr") + work1 := s.CreateTestWork("Test Work 1", "en", "Test content for work 1") + work2 := s.CreateTestWork("Test Work 2", "en", "Test content for work 2") + work3 := s.CreateTestWork("Test Work 3", "fr", "Test content for work 3") // Define the query query := ` @@ -183,6 +151,7 @@ func (s *GraphQLIntegrationSuite) TestQueryWorks() { id name language + content } } ` @@ -197,7 +166,7 @@ func (s *GraphQLIntegrationSuite) TestQueryWorks() { // Verify the response worksData, ok := response.Data["works"].([]interface{}) s.Require().True(ok, "GraphQL response should contain works data") - s.Equal(3, len(worksData), "GraphQL response should contain 3 works") + s.True(len(worksData) >= 3, "GraphQL response should contain at least 3 works") // Verify each work foundWork1 := false @@ -208,18 +177,15 @@ func (s *GraphQLIntegrationSuite) TestQueryWorks() { work, ok := workData.(map[string]interface{}) s.Require().True(ok, "Work data should be a map") - id := work["id"].(string) // fix: treat id as string - if id == fmt.Sprintf("%d", work1.ID) { + name := work["name"].(string) + if name == "Test Work 1" { foundWork1 = true - s.Equal("Test Work 1", work["name"], "Work 1 name should match") s.Equal("en", work["language"], "Work 1 language should match") - } else if id == fmt.Sprintf("%d", work2.ID) { + } else if name == "Test Work 2" { foundWork2 = true - s.Equal("Test Work 2", work["name"], "Work 2 name should match") s.Equal("en", work["language"], "Work 2 language should match") - } else if id == fmt.Sprintf("%d", work3.ID) { + } else if name == "Test Work 3" { foundWork3 = true - s.Equal("Test Work 3", work["name"], "Work 3 name should match") s.Equal("fr", work["language"], "Work 3 language should match") } } @@ -229,12 +195,6 @@ func (s *GraphQLIntegrationSuite) TestQueryWorks() { s.True(foundWork3, "GraphQL response should contain work 3") } -func stringToUint(s string) uint { - var id uint - fmt.Sscanf(s, "%d", &id) - return id -} - // TestCreateWork tests the createWork mutation func (s *GraphQLIntegrationSuite) TestCreateWork() { // Define the mutation @@ -273,18 +233,20 @@ func (s *GraphQLIntegrationSuite) TestCreateWork() { s.Equal("en", workData["language"], "Work language should match") s.Equal("New test content", workData["content"], "Work content should match") - // Verify that the work was created in the mock repository - var found *models.Work - for _, w := range s.workRepo.Works { + // Verify that the work was created in the repository + // Since we're using the real repository interface, we can query it + works, err := s.WorkRepo.ListAll(context.Background()) + s.Require().NoError(err) + + var found bool + for _, w := range works { if w.Title == "New Test Work" { - found = w + found = true + s.Equal("en", w.Language, "Work language should be set correctly") break } } - s.Require().NotNil(found) - s.Equal("New Test Work", found.Title) - s.Equal("en", found.Language) - // Content is not stored on Work model; translations hold content + s.True(found, "Work should be created in repository") } // TestGraphQLIntegrationSuite runs the test suite diff --git a/graph/resolver.go b/graph/resolver.go index 67273ed..5d9b63e 100644 --- a/graph/resolver.go +++ b/graph/resolver.go @@ -1,7 +1,7 @@ package graph import ( - "tercul/repositories" + repositories2 "tercul/internal/repositories" "tercul/services" ) @@ -10,16 +10,16 @@ import ( // It serves as dependency injection for your app, add any dependencies you require here. type Resolver struct { - WorkRepo repositories.WorkRepository - UserRepo repositories.UserRepository - AuthorRepo repositories.AuthorRepository - TranslationRepo repositories.TranslationRepository - CommentRepo repositories.CommentRepository - LikeRepo repositories.LikeRepository - BookmarkRepo repositories.BookmarkRepository - CollectionRepo repositories.CollectionRepository - TagRepo repositories.TagRepository - CategoryRepo repositories.CategoryRepository + WorkRepo repositories2.WorkRepository + UserRepo repositories2.UserRepository + AuthorRepo repositories2.AuthorRepository + TranslationRepo repositories2.TranslationRepository + CommentRepo repositories2.CommentRepository + LikeRepo repositories2.LikeRepository + BookmarkRepo repositories2.BookmarkRepository + CollectionRepo repositories2.CollectionRepository + TagRepo repositories2.TagRepository + CategoryRepo repositories2.CategoryRepository WorkService services.WorkService Localization services.LocalizationService AuthService services.AuthService diff --git a/graph/schema.resolvers.go b/graph/schema.resolvers.go index 89bef1b..6187b97 100644 --- a/graph/schema.resolvers.go +++ b/graph/schema.resolvers.go @@ -9,8 +9,7 @@ import ( "fmt" "strconv" "tercul/graph/model" - "tercul/internal/testutil" - "tercul/models" + models2 "tercul/internal/models" "tercul/services" ) @@ -81,16 +80,49 @@ func (r *mutationResolver) Login(ctx context.Context, email string, password str // CreateWork is the resolver for the createWork field. func (r *mutationResolver) CreateWork(ctx context.Context, input model.WorkInput) (*model.Work, error) { - work := &model.Work{ - ID: fmt.Sprintf("%d", len(r.WorkRepo.(*testutil.UnifiedMockWorkRepository).Works)+1), - Name: input.Name, - Language: input.Language, - Content: input.Content, + // Create work model + work := &models2.Work{ + Title: input.Name, } - m := &models.Work{Title: input.Name} - m.Language = input.Language - r.WorkRepo.(*testutil.UnifiedMockWorkRepository).AddWork(m) - return work, nil + work.Language = input.Language // Set language on the embedded TranslatableModel + + // Create work using the work service + err := r.WorkService.CreateWork(ctx, work) + if err != nil { + return nil, err + } + + // If content is provided and TranslationRepo is available, create a translation for it + if input.Content != nil && *input.Content != "" && r.TranslationRepo != nil { + translation := &models2.Translation{ + Title: input.Name, + Content: *input.Content, + Language: input.Language, + TranslatableID: work.ID, + TranslatableType: "Work", + IsOriginalLanguage: true, + } + + err = r.TranslationRepo.Create(ctx, translation) + if err != nil { + return nil, fmt.Errorf("failed to create translation: %v", err) + } + } + + // Return work with resolved content using the localization service + var content *string + if r.Localization != nil { + if resolvedContent, err := r.Localization.GetWorkContent(ctx, work.ID, input.Language); err == nil && resolvedContent != "" { + content = &resolvedContent + } + } + + return &model.Work{ + ID: fmt.Sprintf("%d", work.ID), + Name: work.Title, + Language: input.Language, + Content: content, + }, nil } // UpdateWork is the resolver for the updateWork field. @@ -265,23 +297,34 @@ func (r *mutationResolver) ChangePassword(ctx context.Context, currentPassword s // Work is the resolver for the work field. func (r *queryResolver) Work(ctx context.Context, id string) (*model.Work, error) { - for _, w := range r.WorkRepo.(*testutil.UnifiedMockWorkRepository).Works { - if fmt.Sprintf("%d", w.ID) == id { - // Content resolved via Localization service when requested later - return &model.Work{ - ID: id, - Name: w.Title, - Language: w.Language, - Content: r.resolveWorkContent(ctx, w.ID, w.Language), - }, nil - } + // Parse ID to uint + workID, err := strconv.ParseUint(id, 10, 32) + if err != nil { + return nil, fmt.Errorf("invalid work ID: %v", err) } - return nil, nil + + // Get work by ID using repository + work, err := r.WorkRepo.GetByID(ctx, uint(workID)) + if err != nil { + return nil, err + } + + if work == nil { + return nil, nil + } + + // Content resolved via Localization service when requested later + return &model.Work{ + ID: id, + Name: work.Title, + Language: work.Language, + Content: r.resolveWorkContent(ctx, work.ID, work.Language), + }, nil } // Works is the resolver for the works field. func (r *queryResolver) Works(ctx context.Context, limit *int32, offset *int32, language *string, authorID *string, categoryID *string, tagID *string, search *string) ([]*model.Work, error) { - var works []models.Work + var works []models2.Work var err error // Set default pagination @@ -368,7 +411,7 @@ func (r *queryResolver) Author(ctx context.Context, id string) (*model.Author, e // Authors is the resolver for the authors field. func (r *queryResolver) Authors(ctx context.Context, limit *int32, offset *int32, search *string, countryID *string) ([]*model.Author, error) { - var authors []models.Author + var authors []models2.Author var err error if countryID != nil { @@ -426,23 +469,23 @@ func (r *queryResolver) UserByUsername(ctx context.Context, username string) (*m // Users is the resolver for the users field. func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, role *model.UserRole) ([]*model.User, error) { - var users []models.User + var users []models2.User var err error if role != nil { // Convert GraphQL role to model role - var modelRole models.UserRole + var modelRole models2.UserRole switch *role { case model.UserRoleReader: - modelRole = models.UserRoleReader + modelRole = models2.UserRoleReader case model.UserRoleContributor: - modelRole = models.UserRoleContributor + modelRole = models2.UserRoleContributor case model.UserRoleReviewer: - modelRole = models.UserRoleReviewer + modelRole = models2.UserRoleReviewer case model.UserRoleEditor: - modelRole = models.UserRoleEditor + modelRole = models2.UserRoleEditor case model.UserRoleAdmin: - modelRole = models.UserRoleAdmin + modelRole = models2.UserRoleAdmin default: return nil, fmt.Errorf("invalid user role: %s", *role) } @@ -465,15 +508,15 @@ func (r *queryResolver) Users(ctx context.Context, limit *int32, offset *int32, // Convert model role to GraphQL role var graphqlRole model.UserRole switch u.Role { - case models.UserRoleReader: + case models2.UserRoleReader: graphqlRole = model.UserRoleReader - case models.UserRoleContributor: + case models2.UserRoleContributor: graphqlRole = model.UserRoleContributor - case models.UserRoleReviewer: + case models2.UserRoleReviewer: graphqlRole = model.UserRoleReviewer - case models.UserRoleEditor: + case models2.UserRoleEditor: graphqlRole = model.UserRoleEditor - case models.UserRoleAdmin: + case models2.UserRoleAdmin: graphqlRole = model.UserRoleAdmin default: graphqlRole = model.UserRoleReader diff --git a/graph/server.go b/graph/server.go index 3a4e323..53d6d8e 100644 --- a/graph/server.go +++ b/graph/server.go @@ -3,19 +3,18 @@ package graph import ( "net/http" + "tercul/internal/platform/auth" + "github.com/99designs/gqlgen/graphql/handler" - "github.com/99designs/gqlgen/graphql/playground" - "tercul/auth" ) // NewServer creates a new GraphQL server with the given resolver func NewServer(resolver *Resolver) http.Handler { srv := handler.NewDefaultServer(NewExecutableSchema(Config{Resolvers: resolver})) - // Create a mux to handle both GraphQL and playground + // Create a mux to handle GraphQL endpoint only (no playground here; served separately in production) mux := http.NewServeMux() mux.Handle("/query", srv) - mux.Handle("/", playground.Handler("GraphQL playground", "/query")) return mux } @@ -27,10 +26,9 @@ func NewServerWithAuth(resolver *Resolver, jwtManager *auth.JWTManager) http.Han // Apply authentication middleware to GraphQL endpoint authHandler := auth.GraphQLAuthMiddleware(jwtManager)(srv) - // Create a mux to handle both GraphQL and playground + // Create a mux to handle GraphQL endpoint only (no playground here; served separately in production) mux := http.NewServeMux() mux.Handle("/query", authHandler) - mux.Handle("/", playground.Handler("GraphQL playground", "/query")) return mux } diff --git a/internal/app/application_builder.go b/internal/app/application_builder.go index e863995..0303287 100644 --- a/internal/app/application_builder.go +++ b/internal/app/application_builder.go @@ -1,12 +1,12 @@ package app import ( - "tercul/cache" - "tercul/config" - "tercul/db" + "tercul/internal/platform/cache" + "tercul/internal/platform/config" + "tercul/internal/platform/db" + "tercul/internal/platform/log" + repositories2 "tercul/internal/repositories" "tercul/linguistics" - "tercul/logger" - "tercul/repositories" "tercul/services" "time" @@ -28,17 +28,17 @@ type ApplicationBuilder struct { // RepositoryContainer holds all repository instances type RepositoryContainer struct { - WorkRepository repositories.WorkRepository - UserRepository repositories.UserRepository - AuthorRepository repositories.AuthorRepository - TranslationRepository repositories.TranslationRepository - CommentRepository repositories.CommentRepository - LikeRepository repositories.LikeRepository - BookmarkRepository repositories.BookmarkRepository - CollectionRepository repositories.CollectionRepository - TagRepository repositories.TagRepository - CategoryRepository repositories.CategoryRepository - CopyrightRepository repositories.CopyrightRepository + WorkRepository repositories2.WorkRepository + UserRepository repositories2.UserRepository + AuthorRepository repositories2.AuthorRepository + TranslationRepository repositories2.TranslationRepository + CommentRepository repositories2.CommentRepository + LikeRepository repositories2.LikeRepository + BookmarkRepository repositories2.BookmarkRepository + CollectionRepository repositories2.CollectionRepository + TagRepository repositories2.TagRepository + CategoryRepository repositories2.CategoryRepository + CopyrightRepository repositories2.CopyrightRepository } // ServiceContainer holds all service instances @@ -56,38 +56,38 @@ func NewApplicationBuilder() *ApplicationBuilder { // BuildDatabase initializes the database connection func (b *ApplicationBuilder) BuildDatabase() error { - logger.LogInfo("Initializing database connection") + log.LogInfo("Initializing database connection") dbConn, err := db.InitDB() if err != nil { - logger.LogFatal("Failed to initialize database - application cannot start without database connection", - logger.F("error", err), - logger.F("host", config.Cfg.DBHost), - logger.F("database", config.Cfg.DBName)) + log.LogFatal("Failed to initialize database - application cannot start without database connection", + log.F("error", err), + log.F("host", config.Cfg.DBHost), + log.F("database", config.Cfg.DBName)) return err } b.dbConn = dbConn - logger.LogInfo("Database initialized successfully", - logger.F("host", config.Cfg.DBHost), - logger.F("database", config.Cfg.DBName)) + log.LogInfo("Database initialized successfully", + log.F("host", config.Cfg.DBHost), + log.F("database", config.Cfg.DBName)) return nil } // BuildCache initializes the Redis cache func (b *ApplicationBuilder) BuildCache() error { - logger.LogInfo("Initializing Redis cache") + log.LogInfo("Initializing Redis cache") redisCache, err := cache.NewDefaultRedisCache() if err != nil { - logger.LogWarn("Failed to initialize Redis cache, continuing without caching - performance may be degraded", - logger.F("error", err), - logger.F("redisAddr", config.Cfg.RedisAddr)) + log.LogWarn("Failed to initialize Redis cache, continuing without caching - performance may be degraded", + log.F("error", err), + log.F("redisAddr", config.Cfg.RedisAddr)) } else { b.redisCache = redisCache - logger.LogInfo("Redis cache initialized successfully", - logger.F("redisAddr", config.Cfg.RedisAddr)) + log.LogInfo("Redis cache initialized successfully", + log.F("redisAddr", config.Cfg.RedisAddr)) } return nil @@ -95,32 +95,32 @@ func (b *ApplicationBuilder) BuildCache() error { // BuildWeaviate initializes the Weaviate client func (b *ApplicationBuilder) BuildWeaviate() error { - logger.LogInfo("Connecting to Weaviate", - logger.F("host", config.Cfg.WeaviateHost), - logger.F("scheme", config.Cfg.WeaviateScheme)) + log.LogInfo("Connecting to Weaviate", + log.F("host", config.Cfg.WeaviateHost), + log.F("scheme", config.Cfg.WeaviateScheme)) wClient, err := weaviate.NewClient(weaviate.Config{ Scheme: config.Cfg.WeaviateScheme, Host: config.Cfg.WeaviateHost, }) if err != nil { - logger.LogFatal("Failed to create Weaviate client - vector search capabilities will not be available", - logger.F("error", err), - logger.F("host", config.Cfg.WeaviateHost), - logger.F("scheme", config.Cfg.WeaviateScheme)) + log.LogFatal("Failed to create Weaviate client - vector search capabilities will not be available", + log.F("error", err), + log.F("host", config.Cfg.WeaviateHost), + log.F("scheme", config.Cfg.WeaviateScheme)) return err } b.weaviateClient = wClient - logger.LogInfo("Weaviate client initialized successfully") + log.LogInfo("Weaviate client initialized successfully") return nil } // BuildBackgroundJobs initializes Asynq for background job processing func (b *ApplicationBuilder) BuildBackgroundJobs() error { - logger.LogInfo("Setting up background job processing", - logger.F("redisAddr", config.Cfg.RedisAddr)) + log.LogInfo("Setting up background job processing", + log.F("redisAddr", config.Cfg.RedisAddr)) redisOpt := asynq.RedisClientOpt{ Addr: config.Cfg.RedisAddr, @@ -131,41 +131,41 @@ func (b *ApplicationBuilder) BuildBackgroundJobs() error { asynqClient := asynq.NewClient(redisOpt) b.asynqClient = asynqClient - logger.LogInfo("Background job client initialized successfully") + log.LogInfo("Background job client initialized successfully") return nil } // BuildRepositories initializes all repositories func (b *ApplicationBuilder) BuildRepositories() error { - logger.LogInfo("Initializing repositories") + log.LogInfo("Initializing repositories") // Initialize base repositories - baseWorkRepo := repositories.NewWorkRepository(b.dbConn) - userRepo := repositories.NewUserRepository(b.dbConn) - authorRepo := repositories.NewAuthorRepository(b.dbConn) - translationRepo := repositories.NewTranslationRepository(b.dbConn) - commentRepo := repositories.NewCommentRepository(b.dbConn) - likeRepo := repositories.NewLikeRepository(b.dbConn) - bookmarkRepo := repositories.NewBookmarkRepository(b.dbConn) - collectionRepo := repositories.NewCollectionRepository(b.dbConn) - tagRepo := repositories.NewTagRepository(b.dbConn) - categoryRepo := repositories.NewCategoryRepository(b.dbConn) - copyrightRepo := repositories.NewCopyrightRepository(b.dbConn) + baseWorkRepo := repositories2.NewWorkRepository(b.dbConn) + userRepo := repositories2.NewUserRepository(b.dbConn) + authorRepo := repositories2.NewAuthorRepository(b.dbConn) + translationRepo := repositories2.NewTranslationRepository(b.dbConn) + commentRepo := repositories2.NewCommentRepository(b.dbConn) + likeRepo := repositories2.NewLikeRepository(b.dbConn) + bookmarkRepo := repositories2.NewBookmarkRepository(b.dbConn) + collectionRepo := repositories2.NewCollectionRepository(b.dbConn) + tagRepo := repositories2.NewTagRepository(b.dbConn) + categoryRepo := repositories2.NewCategoryRepository(b.dbConn) + copyrightRepo := repositories2.NewCopyrightRepository(b.dbConn) // Wrap work repository with cache if available - var workRepo repositories.WorkRepository + var workRepo repositories2.WorkRepository if b.redisCache != nil { - workRepo = repositories.NewCachedWorkRepository( + workRepo = repositories2.NewCachedWorkRepository( baseWorkRepo, b.redisCache, nil, 30*time.Minute, // Cache work data for 30 minutes ) - logger.LogInfo("Using cached work repository") + log.LogInfo("Using cached work repository") } else { workRepo = baseWorkRepo - logger.LogInfo("Using non-cached work repository") + log.LogInfo("Using non-cached work repository") } b.repositories = &RepositoryContainer{ @@ -182,14 +182,14 @@ func (b *ApplicationBuilder) BuildRepositories() error { CopyrightRepository: copyrightRepo, } - logger.LogInfo("Repositories initialized successfully") + log.LogInfo("Repositories initialized successfully") return nil } // BuildLinguistics initializes the linguistics components func (b *ApplicationBuilder) BuildLinguistics() error { - logger.LogInfo("Initializing linguistic analyzer") + log.LogInfo("Initializing linguistic analyzer") b.linguistics = linguistics.NewLinguisticsFactory( b.dbConn, @@ -198,14 +198,14 @@ func (b *ApplicationBuilder) BuildLinguistics() error { true, // Cache enabled ) - logger.LogInfo("Linguistics components initialized successfully") + log.LogInfo("Linguistics components initialized successfully") return nil } // BuildServices initializes all services func (b *ApplicationBuilder) BuildServices() error { - logger.LogInfo("Initializing service layer") + log.LogInfo("Initializing service layer") workService := services.NewWorkService(b.repositories.WorkRepository, b.linguistics.GetAnalyzer()) copyrightService := services.NewCopyrightService(b.repositories.CopyrightRepository) @@ -219,7 +219,7 @@ func (b *ApplicationBuilder) BuildServices() error { AuthService: authService, } - logger.LogInfo("Services initialized successfully") + log.LogInfo("Services initialized successfully") return nil } @@ -255,7 +255,7 @@ func (b *ApplicationBuilder) Build() error { return err } - logger.LogInfo("Application builder completed successfully") + log.LogInfo("Application builder completed successfully") return nil } diff --git a/internal/app/server_factory.go b/internal/app/server_factory.go index d261afc..2f74369 100644 --- a/internal/app/server_factory.go +++ b/internal/app/server_factory.go @@ -2,11 +2,11 @@ package app import ( "net/http" - "tercul/auth" - "tercul/config" + "tercul/internal/platform/auth" + "tercul/internal/platform/config" "tercul/graph" "tercul/linguistics" - "tercul/logger" + "tercul/internal/platform/log" "tercul/syncjob" "github.com/99designs/gqlgen/graphql/playground" @@ -27,7 +27,7 @@ func NewServerFactory(appBuilder *ApplicationBuilder) *ServerFactory { // CreateGraphQLServer creates and configures the GraphQL server func (f *ServerFactory) CreateGraphQLServer() (*http.Server, error) { - logger.LogInfo("Setting up GraphQL server") + log.LogInfo("Setting up GraphQL server") // Create GraphQL resolver with all dependencies resolver := &graph.Resolver{ @@ -58,15 +58,15 @@ func (f *ServerFactory) CreateGraphQLServer() (*http.Server, error) { Handler: srv, } - logger.LogInfo("GraphQL server created successfully", - logger.F("port", config.Cfg.ServerPort)) + log.LogInfo("GraphQL server created successfully", + log.F("port", config.Cfg.ServerPort)) return httpServer, nil } // CreateBackgroundJobServers creates and configures background job servers func (f *ServerFactory) CreateBackgroundJobServers() ([]*asynq.Server, error) { - logger.LogInfo("Setting up background job servers") + log.LogInfo("Setting up background job servers") redisOpt := asynq.RedisClientOpt{ Addr: config.Cfg.RedisAddr, @@ -77,8 +77,8 @@ func (f *ServerFactory) CreateBackgroundJobServers() ([]*asynq.Server, error) { var servers []*asynq.Server // Setup data synchronization server - logger.LogInfo("Setting up data synchronization server", - logger.F("concurrency", config.Cfg.MaxRetries)) + log.LogInfo("Setting up data synchronization server", + log.F("concurrency", config.Cfg.MaxRetries)) syncServer := asynq.NewServer(redisOpt, asynq.Config{Concurrency: config.Cfg.MaxRetries}) @@ -93,8 +93,8 @@ func (f *ServerFactory) CreateBackgroundJobServers() ([]*asynq.Server, error) { servers = append(servers, syncServer) // Setup linguistic analysis server - logger.LogInfo("Setting up linguistic analysis server", - logger.F("concurrency", config.Cfg.MaxRetries)) + log.LogInfo("Setting up linguistic analysis server", + log.F("concurrency", config.Cfg.MaxRetries)) // Create linguistic sync job linguisticSyncJob := linguistics.NewLinguisticSyncJob( @@ -114,15 +114,15 @@ func (f *ServerFactory) CreateBackgroundJobServers() ([]*asynq.Server, error) { // This is a temporary workaround - in production, you'd want to properly configure the server servers = append(servers, linguisticServer) - logger.LogInfo("Background job servers created successfully", - logger.F("serverCount", len(servers))) + log.LogInfo("Background job servers created successfully", + log.F("serverCount", len(servers))) return servers, nil } // CreatePlaygroundServer creates the GraphQL playground server func (f *ServerFactory) CreatePlaygroundServer() *http.Server { - logger.LogInfo("Setting up GraphQL playground") + log.LogInfo("Setting up GraphQL playground") playgroundHandler := playground.Handler("GraphQL", "/query") @@ -131,8 +131,8 @@ func (f *ServerFactory) CreatePlaygroundServer() *http.Server { Handler: playgroundHandler, } - logger.LogInfo("GraphQL playground created successfully", - logger.F("port", config.Cfg.PlaygroundPort)) + log.LogInfo("GraphQL playground created successfully", + log.F("port", config.Cfg.PlaygroundPort)) return playgroundServer } diff --git a/internal/cmd/enrich/main.go b/internal/cmd/enrich/main.go index 1ffe542..bafd3b2 100644 --- a/internal/cmd/enrich/main.go +++ b/internal/cmd/enrich/main.go @@ -10,9 +10,9 @@ import ( "gorm.io/driver/postgres" "gorm.io/gorm" - "tercul/config" "tercul/internal/enrich" "tercul/internal/store" + "tercul/internal/platform/config" ) func main() { diff --git a/internal/enrich/keyword_extractor_test.go b/internal/enrich/keyword_extractor_test.go new file mode 100644 index 0000000..67b5c84 --- /dev/null +++ b/internal/enrich/keyword_extractor_test.go @@ -0,0 +1,27 @@ +package enrich + +import "testing" + +func TestKeywordExtractor_Basic(t *testing.T) { + e := NewKeywordExtractor() + text := Text{Body: "The quick brown fox jumps over the lazy dog. The quick brown fox!"} + keywords, err := e.Extract(text) + if err != nil { + t.Fatalf("Extract returned error: %v", err) + } + if len(keywords) == 0 { + t.Fatalf("expected some keywords, got 0") + } + // Ensure stop words filtered and most frequent word appears first + if keywords[0].Text != "quick" && keywords[0].Text != "brown" && keywords[0].Text != "fox" { + t.Errorf("expected a content word as top keyword, got %q", keywords[0].Text) + } + for _, kw := range keywords { + if kw.Text == "the" || kw.Text == "over" { + t.Errorf("stop word %q should be filtered out", kw.Text) + } + if kw.Relevance <= 0 { + t.Errorf("keyword %q has non-positive relevance", kw.Text) + } + } +} diff --git a/internal/enrich/language_detector_test.go b/internal/enrich/language_detector_test.go new file mode 100644 index 0000000..d02659d --- /dev/null +++ b/internal/enrich/language_detector_test.go @@ -0,0 +1,48 @@ +package enrich + +import "testing" + +func TestLanguageDetector_Detect_EN(t *testing.T) { + d := NewLanguageDetector() + lang, conf, err := d.Detect(Text{Body: " the and is in to of that for the "}) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + if lang != "en" { + t.Fatalf("expected language 'en', got %q", lang) + } + if conf <= 0 { + t.Errorf("expected positive confidence, got %f", conf) + } +} + +func TestLanguageDetector_Detect_ES(t *testing.T) { + d := NewLanguageDetector() + lang, _, _ := d.Detect(Text{Body: " el la es en de que por para el "}) + if lang != "es" { + t.Fatalf("expected language 'es', got %q", lang) + } +} + +func TestLanguageDetector_Detect_FR(t *testing.T) { + d := NewLanguageDetector() + lang, _, _ := d.Detect(Text{Body: " le la est en de que pour dans le "}) + if lang != "fr" { + t.Fatalf("expected language 'fr', got %q", lang) + } +} + +func TestLanguageDetector_Detect_DefaultEnglish(t *testing.T) { + d := NewLanguageDetector() + // Balanced/unknown should default to English per implementation + lang, conf, err := d.Detect(Text{Body: " lorem ipsum dolor sit amet "}) + if err != nil { + t.Fatalf("Detect returned error: %v", err) + } + if lang != "en" { + t.Fatalf("expected default language 'en', got %q", lang) + } + if conf != 0.5 { + t.Errorf("expected default confidence 0.5, got %f", conf) + } +} diff --git a/internal/enrich/lemmatizer_test.go b/internal/enrich/lemmatizer_test.go new file mode 100644 index 0000000..bb26961 --- /dev/null +++ b/internal/enrich/lemmatizer_test.go @@ -0,0 +1,47 @@ +package enrich + +import "testing" + +func TestLemmatizer_English(t *testing.T) { + l := NewLemmatizer() + cases := []struct{ + in, want string + }{ + {"Went", "go"}, + {"running", "run"}, + {"studies", "study"}, + {"cars", "car"}, + {"looked", "look"}, + } + for _, c := range cases { + got, err := l.Lemma(c.in, "en") + if err != nil { + t.Fatalf("Lemma returned error: %v", err) + } + if got != c.want { + t.Errorf("Lemma(%q) = %q; want %q", c.in, got, c.want) + } + } +} + +func TestLemmatizer_Spanish(t *testing.T) { + l := NewLemmatizer() + got, err := l.Lemma("hablando", "es") + if err != nil { + t.Fatalf("Lemma returned error: %v", err) + } + if got != "habl" { + t.Errorf("Lemma(hablando) = %q; want 'habl'", got) + } +} + +func TestLemmatizer_French(t *testing.T) { + l := NewLemmatizer() + got, err := l.Lemma("parlent", "fr") + if err != nil { + t.Fatalf("Lemma returned error: %v", err) + } + if got != "parl" { + t.Errorf("Lemma(parlent) = %q; want 'parl'", got) + } +} diff --git a/internal/enrich/phonetic_encoder_test.go b/internal/enrich/phonetic_encoder_test.go new file mode 100644 index 0000000..664c037 --- /dev/null +++ b/internal/enrich/phonetic_encoder_test.go @@ -0,0 +1,43 @@ +package enrich + +import "testing" + +func TestPhoneticEncoder_Soundex(t *testing.T) { + e := NewPhoneticEncoder() + cases := map[string]string{ + "Robert": "R163", + "Ashcraft": "A261", + } + for in, want := range cases { + got := e.Encode(in) + if got != want { + t.Errorf("Encode(%q) = %q; want %q", in, got, want) + } + } + // property checks + if got := e.Encode("P"); got != "P000" { + t.Errorf("Encode(P) = %q; want P000", got) + } +} + +func TestPhoneticEncoder_DoubleMetaphoneVariation(t *testing.T) { + e := NewPhoneticEncoder() + p, s := e.DoubleMetaphone("Robert") + if p != "R163" { + t.Fatalf("primary code = %q; want R163", p) + } + if s == p || len(s) != len(p) { + t.Errorf("secondary variation should differ but have same length: p=%q s=%q", p, s) + } +} + +func TestPhoneticEncoder_Empty(t *testing.T) { + e := NewPhoneticEncoder() + if got := e.Encode(""); got != "" { + t.Errorf("Encode(\"\") = %q; want empty", got) + } + p, s := e.DoubleMetaphone("") + if p != "" || s != "" { + t.Errorf("DoubleMetaphone(\"\") = (%q,%q); want empty codes", p, s) + } +} diff --git a/internal/enrich/poetic_analyzer_test.go b/internal/enrich/poetic_analyzer_test.go new file mode 100644 index 0000000..2274b52 --- /dev/null +++ b/internal/enrich/poetic_analyzer_test.go @@ -0,0 +1,32 @@ +package enrich + +import "testing" + +func TestPoeticAnalyzer_QuatrainABAB(t *testing.T) { + poem := `In silver light we wander far, light +A gentle breeze across the sea, breeze +At quiet dusk we find a star, night +And in the dark we feel the freeze.` + // Last words: light, breeze, night, freeze -> ABAB by last 2 letters (ht, ze, ht, ze) + p := NewPoeticAnalyzer() + m, err := p.Analyse(Text{Body: poem}) + if err != nil { + t.Fatalf("Analyse returned error: %v", err) + } + if m.RhymeScheme != "ABAB" { + t.Errorf("expected rhyme scheme ABAB, got %q", m.RhymeScheme) + } + if m.StanzaCount != 1 { + t.Errorf("expected 1 stanza, got %d", m.StanzaCount) + } + if m.LineCount != 4 { + t.Errorf("expected 4 lines, got %d", m.LineCount) + } + if m.Structure != "Quatrain" { + t.Errorf("expected structure Quatrain, got %q", m.Structure) + } + // Meter is heuristic; just ensure it's determined (not Unknown) + if m.MeterType == "Unknown" { + t.Errorf("expected a determined meter type, got %q", m.MeterType) + } +} diff --git a/internal/enrich/pos_tagger_test.go b/internal/enrich/pos_tagger_test.go new file mode 100644 index 0000000..c6bd1c1 --- /dev/null +++ b/internal/enrich/pos_tagger_test.go @@ -0,0 +1,29 @@ +package enrich + +import "testing" + +func TestPOSTagger_Tag_Basic(t *testing.T) { + pos := NewPOSTagger() + tokens := []Token{ + {Text: "the"}, // DET + {Text: "great"}, // ADJ (in common adjectives) + {Text: "fox"}, // default NOUN + {Text: "jumps"}, // VERB by suffix + {Text: "quickly"},// ADV by -ly + {Text: "over"}, // PREP + {Text: "him"}, // PRON + } + tags, err := pos.Tag(tokens) + if err != nil { + t.Fatalf("Tag returned error: %v", err) + } + expected := []string{"DET", "ADJ", "NOUN", "VERB", "ADV", "PREP", "PRON"} + if len(tags) != len(expected) { + t.Fatalf("expected %d tags, got %d: %#v", len(expected), len(tags), tags) + } + for i := range expected { + if tags[i] != expected[i] { + t.Errorf("tag %d: expected %s, got %s", i, expected[i], tags[i]) + } + } +} diff --git a/internal/enrich/tokenizer_test.go b/internal/enrich/tokenizer_test.go new file mode 100644 index 0000000..33d3404 --- /dev/null +++ b/internal/enrich/tokenizer_test.go @@ -0,0 +1,58 @@ +package enrich + +import "testing" + +func TestTokenizer_Basic(t *testing.T) { + text := Text{Body: "Hello, world! Go1 is great."} + tok := NewTokenizer() + tokens, err := tok.Tokenize(text) + if err != nil { + t.Fatalf("Tokenize returned error: %v", err) + } + + expected := []string{"Hello", "world", "Go1", "is", "great"} + if len(tokens) != len(expected) { + t.Fatalf("expected %d tokens, got %d: %#v", len(expected), len(tokens), tokens) + } + for i, e := range expected { + if tokens[i].Text != e { + t.Errorf("token %d text: expected %q, got %q", i, e, tokens[i].Text) + } + if tokens[i].Position != i { + t.Errorf("token %d position: expected %d, got %d", i, i, tokens[i].Position) + } + if tokens[i].Length != len(e) { + t.Errorf("token %d length: expected %d, got %d", i, len(e), tokens[i].Length) + } + } +} + +func TestTokenizer_UnicodeAndPunctuation(t *testing.T) { + text := Text{Body: "Привет, мир! — hello?"} + tok := NewTokenizer() + tokens, err := tok.Tokenize(text) + if err != nil { + t.Fatalf("Tokenize returned error: %v", err) + } + + expected := []string{"Привет", "мир", "hello"} + if len(tokens) != len(expected) { + t.Fatalf("expected %d tokens, got %d: %#v", len(expected), len(tokens), tokens) + } + for i, e := range expected { + if tokens[i].Text != e { + t.Errorf("token %d text: expected %q, got %q", i, e, tokens[i].Text) + } + } +} + +func TestTokenizer_Empty(t *testing.T) { + tok := NewTokenizer() + tokens, err := tok.Tokenize(Text{Body: " \t\n "}) + if err != nil { + t.Fatalf("Tokenize returned error: %v", err) + } + if len(tokens) != 0 { + t.Fatalf("expected 0 tokens for whitespace-only input, got %d", len(tokens)) + } +} diff --git a/models/analytics.go b/internal/models/analytics.go similarity index 88% rename from models/analytics.go rename to internal/models/analytics.go index 8d73545..4da7f3e 100644 --- a/models/analytics.go +++ b/internal/models/analytics.go @@ -8,7 +8,7 @@ type WorkStats struct { Comments int64 `gorm:"default:0"` Bookmarks int64 `gorm:"default:0"` Shares int64 `gorm:"default:0"` - WorkID uint + WorkID uint `gorm:"uniqueIndex;index"` Work *Work `gorm:"foreignKey:WorkID"` } @@ -19,7 +19,7 @@ type TranslationStats struct { Likes int64 `gorm:"default:0"` Comments int64 `gorm:"default:0"` Shares int64 `gorm:"default:0"` - TranslationID uint + TranslationID uint `gorm:"uniqueIndex;index"` Translation *Translation `gorm:"foreignKey:TranslationID"` } @@ -32,7 +32,7 @@ type UserStats struct { Comments int64 `gorm:"default:0"` // Number of comments posted Likes int64 `gorm:"default:0"` // Number of likes given Bookmarks int64 `gorm:"default:0"` // Number of bookmarks created - UserID uint + UserID uint `gorm:"uniqueIndex;index"` User *User `gorm:"foreignKey:UserID"` } @@ -42,7 +42,7 @@ type BookStats struct { Sales int64 `gorm:"default:0"` Views int64 `gorm:"default:0"` Likes int64 `gorm:"default:0"` - BookID uint + BookID uint `gorm:"uniqueIndex;index"` Book *Book `gorm:"foreignKey:BookID"` } @@ -52,7 +52,7 @@ type CollectionStats struct { Items int64 `gorm:"default:0"` // Number of works in the collection Views int64 `gorm:"default:0"` Likes int64 `gorm:"default:0"` - CollectionID uint + CollectionID uint `gorm:"uniqueIndex;index"` Collection *Collection `gorm:"foreignKey:CollectionID"` } @@ -62,6 +62,6 @@ type MediaStats struct { Views int64 `gorm:"default:0"` Downloads int64 `gorm:"default:0"` Shares int64 `gorm:"default:0"` - MediaID uint + MediaID uint `gorm:"uniqueIndex;index"` Media interface{} `gorm:"-"` // This would be a pointer to a Media type if it existed } diff --git a/models/base.go b/internal/models/base.go similarity index 78% rename from models/base.go rename to internal/models/base.go index 2d30d0c..1914032 100644 --- a/models/base.go +++ b/internal/models/base.go @@ -20,11 +20,26 @@ func (j JSONB) Value() (driver.Value, error) { // Scan unmarshals a JSONB value. func (j *JSONB) Scan(value interface{}) error { - bytes, ok := value.([]byte) - if !ok { - return fmt.Errorf("failed to unmarshal JSONB value: %v", value) + if value == nil { + *j = JSONB{} + return nil + } + switch v := value.(type) { + case []byte: + if len(v) == 0 { + *j = JSONB{} + return nil + } + return json.Unmarshal(v, j) + case string: + if v == "" { + *j = JSONB{} + return nil + } + return json.Unmarshal([]byte(v), j) + default: + return fmt.Errorf("failed to unmarshal JSONB value of type %T: %v", value, value) } - return json.Unmarshal(bytes, j) } // BaseModel contains common fields for all models diff --git a/models/interaction.go b/internal/models/interaction.go similarity index 83% rename from models/interaction.go rename to internal/models/interaction.go index 7f91504..aa63c29 100644 --- a/models/interaction.go +++ b/internal/models/interaction.go @@ -26,13 +26,13 @@ type Comment struct { // Like represents a user like on a work, translation, or comment type Like struct { BaseModel - UserID uint + UserID uint `gorm:"index;uniqueIndex:uniq_like_user_target"` User *User `gorm:"foreignKey:UserID"` - WorkID *uint + WorkID *uint `gorm:"index;uniqueIndex:uniq_like_user_target"` Work *Work `gorm:"foreignKey:WorkID"` - TranslationID *uint + TranslationID *uint `gorm:"index;uniqueIndex:uniq_like_user_target"` Translation *Translation `gorm:"foreignKey:TranslationID"` - CommentID *uint + CommentID *uint `gorm:"index;uniqueIndex:uniq_like_user_target"` Comment *Comment `gorm:"foreignKey:CommentID"` } @@ -40,9 +40,9 @@ type Like struct { type Bookmark struct { BaseModel Name string `gorm:"size:100"` - UserID uint - User *User `gorm:"foreignKey:UserID"` - WorkID uint + UserID uint `gorm:"index;uniqueIndex:uniq_bookmark_user_work"` + User *User `gorm:"foreignKey:UserID"` + WorkID uint `gorm:"index;uniqueIndex:uniq_bookmark_user_work"` Work *Work `gorm:"foreignKey:WorkID"` Notes string `gorm:"type:text"` LastReadAt *time.Time diff --git a/models/junction_tables.go b/internal/models/junction_tables.go similarity index 59% rename from models/junction_tables.go rename to internal/models/junction_tables.go index 8e0d793..ec5ceac 100644 --- a/models/junction_tables.go +++ b/internal/models/junction_tables.go @@ -3,9 +3,9 @@ package models // BookWork represents the many-to-many relationship between books and works type BookWork struct { BaseModel - BookID uint + BookID uint `gorm:"index;uniqueIndex:uniq_book_work"` Book *Book `gorm:"foreignKey:BookID"` - WorkID uint + WorkID uint `gorm:"index;uniqueIndex:uniq_book_work"` Work *Work `gorm:"foreignKey:WorkID"` Order int `gorm:"default:0"` // For ordering works in books } @@ -13,30 +13,30 @@ type BookWork struct { // AuthorCountry represents the many-to-many relationship between authors and countries type AuthorCountry struct { BaseModel - AuthorID uint + AuthorID uint `gorm:"index;uniqueIndex:uniq_author_country"` Author *Author `gorm:"foreignKey:AuthorID"` - CountryID uint + CountryID uint `gorm:"index;uniqueIndex:uniq_author_country"` Country *Country `gorm:"foreignKey:CountryID"` } // WorkAuthor represents authorship with role and order for a work type WorkAuthor struct { BaseModel - WorkID uint + WorkID uint `gorm:"index;uniqueIndex:uniq_work_author_role"` Work *Work `gorm:"foreignKey:WorkID"` - AuthorID uint + AuthorID uint `gorm:"index;uniqueIndex:uniq_work_author_role"` Author *Author `gorm:"foreignKey:AuthorID"` - Role string `gorm:"size:50;default:'author'"` + Role string `gorm:"size:50;default:'author';uniqueIndex:uniq_work_author_role"` Ordinal int `gorm:"default:0"` } // BookAuthor represents book-level contributor role and order type BookAuthor struct { BaseModel - BookID uint + BookID uint `gorm:"index;uniqueIndex:uniq_book_author_role"` Book *Book `gorm:"foreignKey:BookID"` - AuthorID uint + AuthorID uint `gorm:"index;uniqueIndex:uniq_book_author_role"` Author *Author `gorm:"foreignKey:AuthorID"` - Role string `gorm:"size:50;default:'author'"` + Role string `gorm:"size:50;default:'author';uniqueIndex:uniq_book_author_role"` Ordinal int `gorm:"default:0"` } diff --git a/models/linguistic.go b/internal/models/linguistic.go similarity index 94% rename from models/linguistic.go rename to internal/models/linguistic.go index ad4a9ad..df53d96 100644 --- a/models/linguistic.go +++ b/internal/models/linguistic.go @@ -23,13 +23,13 @@ type WritingStyle struct { // LinguisticLayer represents a linguistic layer of analysis type LinguisticLayer struct { BaseModel - Name string `gorm:"size:100;not null"` + Name string `gorm:"size:100;not null"` Description string `gorm:"type:text"` Language string `gorm:"size:50;not null"` Type string `gorm:"size:50"` // e.g., morphological, syntactic, semantic, etc. - WorkID uint - Work *Work `gorm:"foreignKey:WorkID"` - Data JSONB `gorm:"type:jsonb;default:'{}'"` + WorkID uint + Work *Work `gorm:"foreignKey:WorkID"` + Data JSONB `gorm:"type:jsonb;default:'{}'"` } // TextBlock represents a fine-grained unit of text @@ -91,8 +91,8 @@ type WordOccurrence struct { TextBlockID uint TextBlock *TextBlock `gorm:"foreignKey:TextBlockID"` WordID *uint - Word *Word `gorm:"foreignKey:WordID"` - StartOffset int `gorm:"default:0"` + Word *Word `gorm:"foreignKey:WordID"` + StartOffset int `gorm:"default:0"` EndOffset int `gorm:"default:0"` Lemma string `gorm:"size:100"` PartOfSpeech string `gorm:"size:20"` diff --git a/models/literary.go b/internal/models/literary.go similarity index 100% rename from models/literary.go rename to internal/models/literary.go diff --git a/models/location.go b/internal/models/location.go similarity index 100% rename from models/location.go rename to internal/models/location.go diff --git a/models/metadata.go b/internal/models/metadata.go similarity index 54% rename from models/metadata.go rename to internal/models/metadata.go index ee966bf..1fe38e1 100644 --- a/models/metadata.go +++ b/internal/models/metadata.go @@ -7,35 +7,35 @@ import ( // LanguageAnalysis represents language analysis for a work type LanguageAnalysis struct { BaseModel - Language string `gorm:"size:50;not null"` + Language string `gorm:"size:50;not null;uniqueIndex:uniq_work_language_analysis"` Analysis JSONB `gorm:"type:jsonb;default:'{}'"` - WorkID uint - Work *Work `gorm:"foreignKey:WorkID"` + WorkID uint `gorm:"index;uniqueIndex:uniq_work_language_analysis"` + Work *Work `gorm:"foreignKey:WorkID"` } // Gamification represents gamification elements for a user type Gamification struct { BaseModel - Points int `gorm:"default:0"` - Level int `gorm:"default:1"` - Badges JSONB `gorm:"type:jsonb;default:'{}'"` - Streaks int `gorm:"default:0"` + Points int `gorm:"default:0"` + Level int `gorm:"default:1"` + Badges JSONB `gorm:"type:jsonb;default:'{}'"` + Streaks int `gorm:"default:0"` LastActive *time.Time - UserID uint - User *User `gorm:"foreignKey:UserID"` + UserID uint `gorm:"uniqueIndex;index"` + User *User `gorm:"foreignKey:UserID"` } // Stats represents general statistics type Stats struct { BaseModel - Data JSONB `gorm:"type:jsonb;default:'{}'"` - Period string `gorm:"size:50"` // e.g., daily, weekly, monthly, etc. + Data JSONB `gorm:"type:jsonb;default:'{}'"` + Period string `gorm:"size:50"` // e.g., daily, weekly, monthly, etc. StartDate time.Time EndDate time.Time - UserID *uint - User *User `gorm:"foreignKey:UserID"` - WorkID *uint - Work *Work `gorm:"foreignKey:WorkID"` + UserID *uint + User *User `gorm:"foreignKey:UserID"` + WorkID *uint + Work *Work `gorm:"foreignKey:WorkID"` } // SearchDocument is a denormalized text representation for indexing diff --git a/models/organization.go b/internal/models/organization.go similarity index 86% rename from models/organization.go rename to internal/models/organization.go index 635243a..8917ae0 100644 --- a/models/organization.go +++ b/internal/models/organization.go @@ -32,9 +32,9 @@ type Series struct { // WorkSeries is a join capturing a work's position in a series type WorkSeries struct { BaseModel - WorkID uint - Work *Work `gorm:"foreignKey:WorkID"` - SeriesID uint + WorkID uint `gorm:"index;uniqueIndex:uniq_work_series"` + Work *Work `gorm:"foreignKey:WorkID"` + SeriesID uint `gorm:"index;uniqueIndex:uniq_work_series"` Series *Series `gorm:"foreignKey:SeriesID"` NumberInSeries int `gorm:"default:0"` } diff --git a/models/polymorphic_translations.go b/internal/models/polymorphic_translations.go similarity index 100% rename from models/polymorphic_translations.go rename to internal/models/polymorphic_translations.go diff --git a/models/psychological.go b/internal/models/psychological.go similarity index 100% rename from models/psychological.go rename to internal/models/psychological.go diff --git a/models/relationship.go b/internal/models/relationship.go similarity index 66% rename from models/relationship.go rename to internal/models/relationship.go index 526ecb5..a61e5cc 100644 --- a/models/relationship.go +++ b/internal/models/relationship.go @@ -3,12 +3,12 @@ package models // Edge represents a polymorphic relationship between entities type Edge struct { BaseModel - SourceTable string `gorm:"size:50;not null"` - SourceID uint `gorm:"not null"` - TargetTable string `gorm:"size:50;not null"` - TargetID uint `gorm:"not null"` - Relation string `gorm:"size:50;default:'ASSOCIATED_WITH';not null"` - Language string `gorm:"size:10;default:'en'"` + SourceTable string `gorm:"size:50;not null;index:idx_edge_source;uniqueIndex:uniq_edge"` + SourceID uint `gorm:"not null;index:idx_edge_source;uniqueIndex:uniq_edge"` + TargetTable string `gorm:"size:50;not null;index:idx_edge_target;uniqueIndex:uniq_edge"` + TargetID uint `gorm:"not null;index:idx_edge_target;uniqueIndex:uniq_edge"` + Relation string `gorm:"size:50;default:'ASSOCIATED_WITH';not null;index;uniqueIndex:uniq_edge"` + Language string `gorm:"size:10;default:'en';index;uniqueIndex:uniq_edge"` Extra JSONB `gorm:"type:jsonb;default:'{}'"` } @@ -18,9 +18,9 @@ type Embedding struct { BaseModel // External vector storage reference (e.g., Weaviate object UUID) ExternalID string `gorm:"size:64;index"` - EntityType string `gorm:"size:50;not null"` - EntityID uint `gorm:"not null"` - Model string `gorm:"size:50;not null"` // e.g., bert, gpt, etc. + EntityType string `gorm:"size:50;not null;index:idx_embedding_entity;uniqueIndex:uniq_embedding"` + EntityID uint `gorm:"not null;index:idx_embedding_entity;uniqueIndex:uniq_embedding"` + Model string `gorm:"size:50;not null;uniqueIndex:uniq_embedding"` // e.g., bert, gpt, etc. Dim int `gorm:"default:0"` WorkID *uint Work *Work `gorm:"foreignKey:WorkID"` diff --git a/models/rights.go b/internal/models/rights.go similarity index 88% rename from models/rights.go rename to internal/models/rights.go index 04cf9bf..641d0a5 100644 --- a/models/rights.go +++ b/internal/models/rights.go @@ -52,13 +52,13 @@ const ( // CopyrightClaim represents a copyright claim type CopyrightClaim struct { BaseModel - Details string `gorm:"type:text;not null"` - Status CopyrightClaimStatus `gorm:"size:50;default:'pending'"` - ClaimDate time.Time `gorm:"not null"` + Details string `gorm:"type:text;not null"` + Status CopyrightClaimStatus `gorm:"size:50;default:'pending'"` + ClaimDate time.Time `gorm:"not null"` Resolution string `gorm:"type:text"` ResolvedAt *time.Time - UserID *uint - User *User `gorm:"foreignKey:UserID"` + UserID *uint + User *User `gorm:"foreignKey:UserID"` // Polymorphic relationship - can attach to any entity Claimables []Copyrightable `gorm:"polymorphic:Copyrightable"` } @@ -130,12 +130,12 @@ type ModerationFlag struct { // AuditLog captures changes for governance and traceability type AuditLog struct { BaseModel - ActorID *uint - Actor *User `gorm:"foreignKey:ActorID"` - Action string `gorm:"size:50;not null"` + ActorID *uint + Actor *User `gorm:"foreignKey:ActorID"` + Action string `gorm:"size:50;not null"` EntityType string `gorm:"size:50;not null"` - EntityID uint `gorm:"not null"` - Before JSONB `gorm:"type:jsonb;default:'{}'"` - After JSONB `gorm:"type:jsonb;default:'{}'"` - At time.Time `gorm:"autoCreateTime"` + EntityID uint `gorm:"not null"` + Before JSONB `gorm:"type:jsonb;default:'{}'"` + After JSONB `gorm:"type:jsonb;default:'{}'"` + At time.Time `gorm:"autoCreateTime"` } diff --git a/models/system.go b/internal/models/system.go similarity index 91% rename from models/system.go rename to internal/models/system.go index 0a87e40..fa9971c 100644 --- a/models/system.go +++ b/internal/models/system.go @@ -12,8 +12,8 @@ type Notification struct { Read bool `gorm:"default:false"` Language string `gorm:"size:50;not null"` UserID uint - User *User `gorm:"foreignKey:UserID"` - RelatedID *uint // ID of the related entity (work, comment, etc.) + User *User `gorm:"foreignKey:UserID"` + RelatedID *uint // ID of the related entity (work, comment, etc.) RelatedType string `gorm:"size:50"` // Type of the related entity } @@ -75,8 +75,8 @@ type Contributor struct { type InteractionEvent struct { BaseModel UserID *uint - User *User `gorm:"foreignKey:UserID"` - TargetType string `gorm:"size:50;not null"` // work|translation|comment|collection|media + User *User `gorm:"foreignKey:UserID"` + TargetType string `gorm:"size:50;not null"` // work|translation|comment|collection|media TargetID uint `gorm:"not null"` Kind string `gorm:"size:30;not null"` // view|like|comment|share|bookmark OccurredAt time.Time `gorm:"index"` diff --git a/models/user.go b/internal/models/user.go similarity index 88% rename from models/user.go rename to internal/models/user.go index e0d40c8..2a37fb9 100644 --- a/models/user.go +++ b/internal/models/user.go @@ -58,9 +58,9 @@ type UserProfile struct { // UserSession represents a user session type UserSession struct { BaseModel - UserID uint `gorm:"index"` - User *User `gorm:"foreignKey:UserID"` - Token string `gorm:"size:255;not null;uniqueIndex"` + UserID uint `gorm:"index"` + User *User `gorm:"foreignKey:UserID"` + Token string `gorm:"size:255;not null;uniqueIndex"` IP string `gorm:"size:50"` UserAgent string `gorm:"size:255"` ExpiresAt time.Time `gorm:"not null"` @@ -69,9 +69,9 @@ type UserSession struct { // PasswordReset represents a password reset request type PasswordReset struct { BaseModel - UserID uint `gorm:"index"` - User *User `gorm:"foreignKey:UserID"` - Token string `gorm:"size:255;not null;uniqueIndex"` + UserID uint `gorm:"index"` + User *User `gorm:"foreignKey:UserID"` + Token string `gorm:"size:255;not null;uniqueIndex"` ExpiresAt time.Time `gorm:"not null"` Used bool `gorm:"default:false"` } @@ -79,9 +79,9 @@ type PasswordReset struct { // EmailVerification represents an email verification request type EmailVerification struct { BaseModel - UserID uint `gorm:"index"` - User *User `gorm:"foreignKey:UserID"` - Token string `gorm:"size:255;not null;uniqueIndex"` + UserID uint `gorm:"index"` + User *User `gorm:"foreignKey:UserID"` + Token string `gorm:"size:255;not null;uniqueIndex"` ExpiresAt time.Time `gorm:"not null"` Used bool `gorm:"default:false"` } diff --git a/models/user_test.go b/internal/models/user_test.go similarity index 91% rename from models/user_test.go rename to internal/models/user_test.go index 65e60b0..9cf1f7e 100644 --- a/models/user_test.go +++ b/internal/models/user_test.go @@ -1,14 +1,13 @@ package models_test import ( + models2 "tercul/internal/models" "testing" - "tercul/internal/testutil" - "tercul/models" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" "golang.org/x/crypto/bcrypt" + "tercul/internal/testutil" ) // UserModelSuite is a test suite for the User model @@ -16,28 +15,28 @@ import ( type UserModelSuite struct { suite.Suite - users []*models.User + users []*models2.User } func (s *UserModelSuite) SetupSuite() { - s.users = []*models.User{} + s.users = []*models2.User{} } func (s *UserModelSuite) SetupTest() { - s.users = []*models.User{} + s.users = []*models2.User{} } // createTestUser creates a test user and stores it in-memory -func (s *UserModelSuite) createTestUser(username, email, password string) *models.User { +func (s *UserModelSuite) createTestUser(username, email, password string) *models2.User { hashed, _ := hashPassword(password) - user := &models.User{ + user := &models2.User{ Username: username, Email: email, Password: hashed, FirstName: "Test", LastName: "User", DisplayName: "Test User", - Role: models.UserRoleReader, + Role: models2.UserRoleReader, Active: true, } s.users = append(s.users, user) @@ -109,14 +108,14 @@ func (s *UserModelSuite) TestUserValidation() { s.NotNil(user.Username, "User should be created with a valid Username") // Invalid email - invalidEmailUser := &models.User{ + invalidEmailUser := &models2.User{ Username: "testuser2", Email: "invalid-email", Password: "password123", FirstName: "Test", LastName: "User", DisplayName: "Test User", - Role: models.UserRoleReader, + Role: models2.UserRoleReader, Active: true, } isValidEmail := func(email string) bool { @@ -125,14 +124,14 @@ func (s *UserModelSuite) TestUserValidation() { s.False(isValidEmail(invalidEmailUser.Email), "User with invalid email should not be created") // Duplicate username - duplicateUsernameUser := &models.User{ + duplicateUsernameUser := &models2.User{ Username: "testuser", Email: "another@example.com", Password: "password123", FirstName: "Test", LastName: "User", DisplayName: "Test User", - Role: models.UserRoleReader, + Role: models2.UserRoleReader, Active: true, } isDuplicateUsername := false @@ -145,14 +144,14 @@ func (s *UserModelSuite) TestUserValidation() { s.True(isDuplicateUsername, "User with duplicate username should not be created") // Duplicate email - duplicateEmailUser := &models.User{ + duplicateEmailUser := &models2.User{ Username: "testuser3", Email: "test@example.com", Password: "password123", FirstName: "Test", LastName: "User", DisplayName: "Test User", - Role: models.UserRoleReader, + Role: models2.UserRoleReader, Active: true, } isDuplicateEmail := false @@ -167,15 +166,15 @@ func (s *UserModelSuite) TestUserValidation() { // TestUserRoles tests the user role enum func (s *UserModelSuite) TestUserRoles() { - roles := []models.UserRole{ - models.UserRoleReader, - models.UserRoleContributor, - models.UserRoleReviewer, - models.UserRoleEditor, - models.UserRoleAdmin, + roles := []models2.UserRole{ + models2.UserRoleReader, + models2.UserRoleContributor, + models2.UserRoleReviewer, + models2.UserRoleEditor, + models2.UserRoleAdmin, } for i, role := range roles { - user := &models.User{ + user := &models2.User{ Username: "testuser" + string(rune(i+'0')), Email: "test" + string(rune(i+'0')) + "@example.com", Password: "password123", @@ -200,14 +199,14 @@ func TestUserModelSuite(t *testing.T) { // TestUserBeforeSave tests the BeforeSave hook directly func TestUserBeforeSave(t *testing.T) { // Create a user with a plain text password - user := &models.User{ + user := &models2.User{ Username: "testuser", Email: "test@example.com", Password: "password123", FirstName: "Test", LastName: "User", DisplayName: "Test User", - Role: models.UserRoleReader, + Role: models2.UserRoleReader, Active: true, } diff --git a/auth/jwt.go b/internal/platform/auth/jwt.go similarity index 98% rename from auth/jwt.go rename to internal/platform/auth/jwt.go index 391c51b..3d0bd0f 100644 --- a/auth/jwt.go +++ b/internal/platform/auth/jwt.go @@ -4,11 +4,11 @@ import ( "errors" "fmt" "strings" + "tercul/internal/models" "time" "github.com/golang-jwt/jwt/v5" - "tercul/config" - "tercul/models" + "tercul/internal/platform/config" ) var ( diff --git a/auth/middleware.go b/internal/platform/auth/middleware.go similarity index 85% rename from auth/middleware.go rename to internal/platform/auth/middleware.go index 58c7cb5..25a0835 100644 --- a/auth/middleware.go +++ b/internal/platform/auth/middleware.go @@ -5,7 +5,7 @@ import ( "net/http" "strings" - "tercul/logger" + "tercul/internal/platform/log" ) // ContextKey is a type for context keys @@ -32,9 +32,9 @@ func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler { authHeader := r.Header.Get("Authorization") tokenString, err := jwtManager.ExtractTokenFromHeader(authHeader) if err != nil { - logger.LogWarn("Authentication failed - missing or invalid token", - logger.F("path", r.URL.Path), - logger.F("error", err)) + log.LogWarn("Authentication failed - missing or invalid token", + log.F("path", r.URL.Path), + log.F("error", err)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -42,9 +42,9 @@ func AuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handler { // Validate token claims, err := jwtManager.ValidateToken(tokenString) if err != nil { - logger.LogWarn("Authentication failed - invalid token", - logger.F("path", r.URL.Path), - logger.F("error", err)) + log.LogWarn("Authentication failed - invalid token", + log.F("path", r.URL.Path), + log.F("error", err)) http.Error(w, "Unauthorized", http.StatusUnauthorized) return } @@ -62,19 +62,19 @@ func RoleMiddleware(requiredRole string) func(http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { claims, ok := r.Context().Value(ClaimsContextKey).(*Claims) if !ok { - logger.LogWarn("Authorization failed - no claims in context", - logger.F("path", r.URL.Path), - logger.F("required_role", requiredRole)) + log.LogWarn("Authorization failed - no claims in context", + log.F("path", r.URL.Path), + log.F("required_role", requiredRole)) http.Error(w, "Forbidden", http.StatusForbidden) return } jwtManager := NewJWTManager() if err := jwtManager.RequireRole(claims.Role, requiredRole); err != nil { - logger.LogWarn("Authorization failed - insufficient role", - logger.F("path", r.URL.Path), - logger.F("user_role", claims.Role), - logger.F("required_role", requiredRole)) + log.LogWarn("Authorization failed - insufficient role", + log.F("path", r.URL.Path), + log.F("user_role", claims.Role), + log.F("required_role", requiredRole)) http.Error(w, "Forbidden", http.StatusForbidden) return } @@ -103,8 +103,8 @@ func GraphQLAuthMiddleware(jwtManager *JWTManager) func(http.Handler) http.Handl } } // If token is invalid, log warning but continue - logger.LogWarn("GraphQL authentication failed - continuing with anonymous access", - logger.F("path", r.URL.Path)) + log.LogWarn("GraphQL authentication failed - continuing with anonymous access", + log.F("path", r.URL.Path)) } // Continue without authentication diff --git a/cache/cache.go b/internal/platform/cache/cache.go similarity index 100% rename from cache/cache.go rename to internal/platform/cache/cache.go diff --git a/internal/platform/cache/cache_test.go b/internal/platform/cache/cache_test.go new file mode 100644 index 0000000..96eb0ec --- /dev/null +++ b/internal/platform/cache/cache_test.go @@ -0,0 +1,68 @@ +package cache + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDefaultKeyGenerator_DefaultPrefix(t *testing.T) { + g := NewDefaultKeyGenerator("") + require.NotNil(t, g) + // Table-driven tests for key generation + tests := []struct { + name string + entity string + id uint + page int + pageSize int + queryName string + params []interface{} + wantEntity string + wantList string + wantQuery string + }{ + { + name: "basic", + entity: "user", + id: 42, + page: 1, + pageSize: 20, + queryName: "byEmail", + params: []interface{}{"foo@bar.com"}, + wantEntity: "tercul:user:id:42", + wantList: "tercul:user:list:1:20", + wantQuery: "tercul:user:byEmail:foo@bar.com", + }, + { + name: "different entity and multiple params", + entity: "work", + id: 7, + page: 3, + pageSize: 15, + queryName: "search", + params: []interface{}{"abc", 2020, true}, + wantEntity: "tercul:work:id:7", + wantList: "tercul:work:list:3:15", + wantQuery: "tercul:work:search:abc:2020:true", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.wantEntity, g.EntityKey(tt.entity, tt.id)) + assert.Equal(t, tt.wantList, g.ListKey(tt.entity, tt.page, tt.pageSize)) + assert.Equal(t, tt.wantQuery, g.QueryKey(tt.entity, tt.queryName, tt.params...)) + }) + } +} + +func TestDefaultKeyGenerator_CustomPrefix(t *testing.T) { + g := NewDefaultKeyGenerator("mypfx:") + require.NotNil(t, g) + + assert.Equal(t, "mypfx:book:id:1", g.EntityKey("book", 1)) + assert.Equal(t, "mypfx:book:list:2:10", g.ListKey("book", 2, 10)) + assert.Equal(t, "mypfx:book:find:tag:99", g.QueryKey("book", "find", "tag", 99)) +} diff --git a/cache/redis_cache.go b/internal/platform/cache/redis_cache.go similarity index 96% rename from cache/redis_cache.go rename to internal/platform/cache/redis_cache.go index e6b2d57..d76ea1c 100644 --- a/cache/redis_cache.go +++ b/internal/platform/cache/redis_cache.go @@ -8,8 +8,8 @@ import ( "time" "github.com/redis/go-redis/v9" - "tercul/config" - "tercul/logger" + "tercul/internal/platform/config" + "tercul/internal/platform/log" ) // RedisCache implements the Cache interface using Redis @@ -112,9 +112,9 @@ func (c *RedisCache) GetMulti(ctx context.Context, keys []string) (map[string][] str, ok := values[i].(string) if !ok { - logger.LogWarn("Invalid type in Redis cache", - logger.F("key", key), - logger.F("type", fmt.Sprintf("%T", values[i]))) + log.LogWarn("Invalid type in Redis cache", + log.F("key", key), + log.F("type", fmt.Sprintf("%T", values[i]))) continue } diff --git a/config/config.go b/internal/platform/config/config.go similarity index 91% rename from config/config.go rename to internal/platform/config/config.go index c4018b6..76b41f4 100644 --- a/config/config.go +++ b/internal/platform/config/config.go @@ -53,8 +53,8 @@ type Config struct { NLPUseTFIDF bool // NLP cache configuration - NLPMemoryCacheCap int - NLPRedisCacheTTLSeconds int + NLPMemoryCacheCap int + NLPRedisCacheTTLSeconds int } // Cfg is the global configuration instance @@ -146,16 +146,16 @@ func getEnvAsInt(key string, defaultValue int) int { // 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 - } + 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 + } } diff --git a/db/db.go b/internal/platform/db/db.go similarity index 83% rename from db/db.go rename to internal/platform/db/db.go index e4da62d..7ad1eb5 100644 --- a/db/db.go +++ b/internal/platform/db/db.go @@ -7,8 +7,8 @@ import ( "gorm.io/driver/postgres" "gorm.io/gorm" gormlogger "gorm.io/gorm/logger" - "tercul/config" - "tercul/logger" + "tercul/internal/platform/config" + "tercul/internal/platform/log" ) // DB is a global database connection instance @@ -17,9 +17,9 @@ var DB *gorm.DB // Connect establishes a connection to the database using configuration settings // It returns the database connection and any error encountered func Connect() (*gorm.DB, error) { - logger.LogInfo("Connecting to database", - logger.F("host", config.Cfg.DBHost), - logger.F("database", config.Cfg.DBName)) + log.LogInfo("Connecting to database", + log.F("host", config.Cfg.DBHost), + log.F("database", config.Cfg.DBName)) dsn := config.Cfg.GetDSN() db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{ @@ -43,9 +43,9 @@ func Connect() (*gorm.DB, error) { sqlDB.SetMaxIdleConns(5) // Idle connections sqlDB.SetConnMaxLifetime(30 * time.Minute) - logger.LogInfo("Successfully connected to database", - logger.F("host", config.Cfg.DBHost), - logger.F("database", config.Cfg.DBName)) + log.LogInfo("Successfully connected to database", + log.F("host", config.Cfg.DBHost), + log.F("database", config.Cfg.DBName)) return db, nil } diff --git a/db/migrations.go b/internal/platform/db/migrations.go similarity index 78% rename from db/migrations.go rename to internal/platform/db/migrations.go index 9ea48d1..31c103a 100644 --- a/db/migrations.go +++ b/internal/platform/db/migrations.go @@ -2,142 +2,142 @@ package db import ( "gorm.io/gorm" - "tercul/logger" - "tercul/models" + models2 "tercul/internal/models" + "tercul/internal/platform/log" ) // RunMigrations runs all database migrations func RunMigrations(db *gorm.DB) error { - logger.LogInfo("Running database migrations") + log.LogInfo("Running database migrations") // First, create all tables using GORM AutoMigrate if err := createTables(db); err != nil { - logger.LogError("Failed to create tables", logger.F("error", err)) + log.LogError("Failed to create tables", log.F("error", err)) return err } // Then add indexes to improve query performance if err := addIndexes(db); err != nil { - logger.LogError("Failed to add indexes", logger.F("error", err)) + log.LogError("Failed to add indexes", log.F("error", err)) return err } - logger.LogInfo("Database migrations completed successfully") + log.LogInfo("Database migrations completed successfully") return nil } // createTables creates all database tables using GORM AutoMigrate func createTables(db *gorm.DB) error { - logger.LogInfo("Creating database tables") + log.LogInfo("Creating database tables") // Enable recommended extensions if err := db.Exec("CREATE EXTENSION IF NOT EXISTS pg_trgm").Error; err != nil { - logger.LogError("Failed to enable pg_trgm extension", logger.F("error", err)) + log.LogError("Failed to enable pg_trgm extension", log.F("error", err)) return err } // Create all models/tables err := db.AutoMigrate( // User-related models - &models.User{}, - &models.UserProfile{}, - &models.UserSession{}, - &models.PasswordReset{}, - &models.EmailVerification{}, + &models2.User{}, + &models2.UserProfile{}, + &models2.UserSession{}, + &models2.PasswordReset{}, + &models2.EmailVerification{}, // Literary models - &models.Work{}, - &models.Translation{}, - &models.Author{}, - &models.Book{}, - &models.Publisher{}, - &models.Source{}, - &models.Edition{}, - &models.Series{}, - &models.WorkSeries{}, + &models2.Work{}, + &models2.Translation{}, + &models2.Author{}, + &models2.Book{}, + &models2.Publisher{}, + &models2.Source{}, + &models2.Edition{}, + &models2.Series{}, + &models2.WorkSeries{}, // Organization models - &models.Tag{}, - &models.Category{}, + &models2.Tag{}, + &models2.Category{}, // Interaction models - &models.Comment{}, - &models.Like{}, - &models.Bookmark{}, - &models.Collection{}, - &models.Contribution{}, - &models.InteractionEvent{}, + &models2.Comment{}, + &models2.Like{}, + &models2.Bookmark{}, + &models2.Collection{}, + &models2.Contribution{}, + &models2.InteractionEvent{}, // Location models - &models.Country{}, - &models.City{}, - &models.Place{}, - &models.Address{}, - &models.Language{}, + &models2.Country{}, + &models2.City{}, + &models2.Place{}, + &models2.Address{}, + &models2.Language{}, // Linguistic models - &models.ReadabilityScore{}, - &models.WritingStyle{}, - &models.LinguisticLayer{}, - &models.TextMetadata{}, - &models.PoeticAnalysis{}, - &models.Word{}, - &models.Concept{}, - &models.LanguageEntity{}, - &models.TextBlock{}, - &models.WordOccurrence{}, - &models.EntityOccurrence{}, + &models2.ReadabilityScore{}, + &models2.WritingStyle{}, + &models2.LinguisticLayer{}, + &models2.TextMetadata{}, + &models2.PoeticAnalysis{}, + &models2.Word{}, + &models2.Concept{}, + &models2.LanguageEntity{}, + &models2.TextBlock{}, + &models2.WordOccurrence{}, + &models2.EntityOccurrence{}, // Relationship models - &models.Edge{}, - &models.Embedding{}, - &models.Media{}, - &models.BookWork{}, - &models.AuthorCountry{}, - &models.WorkAuthor{}, - &models.BookAuthor{}, + &models2.Edge{}, + &models2.Embedding{}, + &models2.Media{}, + &models2.BookWork{}, + &models2.AuthorCountry{}, + &models2.WorkAuthor{}, + &models2.BookAuthor{}, // System models - &models.Notification{}, - &models.EditorialWorkflow{}, - &models.Admin{}, - &models.Vote{}, - &models.Contributor{}, - &models.HybridEntityWork{}, - &models.ModerationFlag{}, - &models.AuditLog{}, + &models2.Notification{}, + &models2.EditorialWorkflow{}, + &models2.Admin{}, + &models2.Vote{}, + &models2.Contributor{}, + &models2.HybridEntityWork{}, + &models2.ModerationFlag{}, + &models2.AuditLog{}, // Rights models - &models.Copyright{}, - &models.CopyrightClaim{}, - &models.Monetization{}, - &models.License{}, + &models2.Copyright{}, + &models2.CopyrightClaim{}, + &models2.Monetization{}, + &models2.License{}, // Analytics models - &models.WorkStats{}, - &models.TranslationStats{}, - &models.UserStats{}, - &models.BookStats{}, - &models.CollectionStats{}, - &models.MediaStats{}, + &models2.WorkStats{}, + &models2.TranslationStats{}, + &models2.UserStats{}, + &models2.BookStats{}, + &models2.CollectionStats{}, + &models2.MediaStats{}, // Metadata models - &models.LanguageAnalysis{}, - &models.Gamification{}, - &models.Stats{}, - &models.SearchDocument{}, + &models2.LanguageAnalysis{}, + &models2.Gamification{}, + &models2.Stats{}, + &models2.SearchDocument{}, // Psychological models - &models.Emotion{}, - &models.Mood{}, - &models.TopicCluster{}, + &models2.Emotion{}, + &models2.Mood{}, + &models2.TopicCluster{}, ) if err != nil { return err } - logger.LogInfo("Database tables created successfully") + log.LogInfo("Database tables created successfully") return nil } @@ -326,6 +326,6 @@ func addIndexes(db *gorm.DB) error { return err } - logger.LogInfo("Database indexes added successfully") + log.LogInfo("Database indexes added successfully") return nil } diff --git a/middleware/rate_limiter.go b/internal/platform/http/rate_limiter.go similarity index 93% rename from middleware/rate_limiter.go rename to internal/platform/http/rate_limiter.go index 4e5dae2..ad7024f 100644 --- a/middleware/rate_limiter.go +++ b/internal/platform/http/rate_limiter.go @@ -1,10 +1,10 @@ -package middleware +package http import ( "net/http" "sync" - "tercul/config" - "tercul/logger" + "tercul/internal/platform/config" + "tercul/internal/platform/log" "time" ) @@ -85,9 +85,9 @@ func RateLimitMiddleware(next http.Handler) http.Handler { // Check if request is allowed if !rateLimiter.Allow(clientID) { - logger.LogWarn("Rate limit exceeded", - logger.F("clientID", clientID), - logger.F("path", r.URL.Path)) + log.LogWarn("Rate limit exceeded", + log.F("clientID", clientID), + log.F("path", r.URL.Path)) w.WriteHeader(http.StatusTooManyRequests) w.Write([]byte("Rate limit exceeded. Please try again later.")) diff --git a/middleware/rate_limiter_test.go b/internal/platform/http/rate_limiter_test.go similarity index 91% rename from middleware/rate_limiter_test.go rename to internal/platform/http/rate_limiter_test.go index bc4ebb3..cfbb9e5 100644 --- a/middleware/rate_limiter_test.go +++ b/internal/platform/http/rate_limiter_test.go @@ -1,4 +1,4 @@ -package middleware_test +package http_test import ( "net/http" @@ -6,8 +6,8 @@ import ( "testing" "time" - "tercul/config" - "tercul/middleware" + "tercul/internal/platform/config" + platformhttp "tercul/internal/platform/http" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" @@ -21,7 +21,7 @@ type RateLimiterSuite struct { // TestRateLimiter tests the RateLimiter func (s *RateLimiterSuite) TestRateLimiter() { // Create a new rate limiter with 2 requests per second and a burst of 3 - limiter := middleware.NewRateLimiter(2, 3) + limiter := platformhttp.NewRateLimiter(2, 3) // Test that the first 3 requests are allowed (burst) for i := 0; i < 3; i++ { @@ -50,7 +50,7 @@ func (s *RateLimiterSuite) TestRateLimiter() { // TestRateLimiterMultipleClients tests the RateLimiter with multiple clients func (s *RateLimiterSuite) TestRateLimiterMultipleClients() { // Create a new rate limiter with 2 requests per second and a burst of 3 - limiter := middleware.NewRateLimiter(2, 3) + limiter := platformhttp.NewRateLimiter(2, 3) // Test that the first 3 requests for client1 are allowed (burst) for i := 0; i < 3; i++ { @@ -85,7 +85,7 @@ func (s *RateLimiterSuite) TestRateLimiterMiddleware() { }) // Create a rate limiter middleware with 2 requests per second and a burst of 3 - middleware := middleware.RateLimitMiddleware(testHandler) + middleware := platformhttp.RateLimitMiddleware(testHandler) // Create a test server server := httptest.NewServer(middleware) @@ -144,22 +144,22 @@ func TestRateLimiterSuite(t *testing.T) { // TestNewRateLimiter tests the NewRateLimiter function func TestNewRateLimiter(t *testing.T) { // Test with valid parameters - limiter := middleware.NewRateLimiter(10, 20) + limiter := platformhttp.NewRateLimiter(10, 20) assert.NotNil(t, limiter, "NewRateLimiter should return a non-nil limiter") // Test with zero rate (should use default) - limiter = middleware.NewRateLimiter(0, 20) + limiter = platformhttp.NewRateLimiter(0, 20) assert.NotNil(t, limiter, "NewRateLimiter should return a non-nil limiter with default rate") // Test with zero capacity (should use default) - limiter = middleware.NewRateLimiter(10, 0) + limiter = platformhttp.NewRateLimiter(10, 0) assert.NotNil(t, limiter, "NewRateLimiter should return a non-nil limiter with default capacity") // Test with negative rate (should use default) - limiter = middleware.NewRateLimiter(-10, 20) + limiter = platformhttp.NewRateLimiter(-10, 20) assert.NotNil(t, limiter, "NewRateLimiter should return a non-nil limiter with default rate") // Test with negative capacity (should use default) - limiter = middleware.NewRateLimiter(10, -20) + limiter = platformhttp.NewRateLimiter(10, -20) assert.NotNil(t, limiter, "NewRateLimiter should return a non-nil limiter with default capacity") } diff --git a/logger/logger.go b/internal/platform/log/logger.go similarity index 99% rename from logger/logger.go rename to internal/platform/log/logger.go index e29e164..e1e18b9 100644 --- a/logger/logger.go +++ b/internal/platform/log/logger.go @@ -1,4 +1,4 @@ -package logger +package log import ( "fmt" diff --git a/weaviate/schema.go b/internal/platform/search/schema.go similarity index 99% rename from weaviate/schema.go rename to internal/platform/search/schema.go index a1e8673..f682775 100644 --- a/weaviate/schema.go +++ b/internal/platform/search/schema.go @@ -1,8 +1,8 @@ -package weaviate +package search import ( "context" - "log" + "fmt" "github.com/weaviate/weaviate-go-client/v5/weaviate" "github.com/weaviate/weaviate/entities/models" @@ -520,9 +520,9 @@ func CreateSchema(client *weaviate.Client) { for _, class := range classes { err := client.Schema().ClassCreator().WithClass(class).Do(context.Background()) if err != nil { - log.Printf("Failed to create class %s: %v", class.Class, err) + fmt.Printf("Failed to create class %s: %v", class.Class, err) } } - log.Println("Weaviate schema created successfully.") + fmt.Println("Weaviate schema created successfully.") } diff --git a/weaviate/weaviate_client.go b/internal/platform/search/weaviate_client.go similarity index 93% rename from weaviate/weaviate_client.go rename to internal/platform/search/weaviate_client.go index 370cf71..b3e5662 100644 --- a/weaviate/weaviate_client.go +++ b/internal/platform/search/weaviate_client.go @@ -1,14 +1,13 @@ -package weaviate +package search import ( "context" "fmt" "log" - "tercul/models" + "tercul/internal/models" + "tercul/internal/platform/config" "time" - "tercul/config" - "github.com/weaviate/weaviate-go-client/v5/weaviate" ) diff --git a/repositories/author_repository.go b/internal/repositories/author_repository.go similarity index 98% rename from repositories/author_repository.go rename to internal/repositories/author_repository.go index b4067d0..9c8ee6a 100644 --- a/repositories/author_repository.go +++ b/internal/repositories/author_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // AuthorRepository defines CRUD methods specific to Author. diff --git a/repositories/base_repository.go b/internal/repositories/base_repository.go similarity index 73% rename from repositories/base_repository.go rename to internal/repositories/base_repository.go index c73fd45..b870331 100644 --- a/repositories/base_repository.go +++ b/internal/repositories/base_repository.go @@ -7,8 +7,8 @@ import ( "time" "gorm.io/gorm" - "tercul/config" - "tercul/logger" + "tercul/internal/platform/config" + "tercul/internal/platform/log" ) // Common repository errors @@ -198,14 +198,14 @@ func (r *BaseRepositoryImpl[T]) Create(ctx context.Context, entity *T) error { duration := time.Since(start) if err != nil { - logger.LogError("Failed to create entity", - logger.F("error", err), - logger.F("duration", duration)) + log.LogError("Failed to create entity", + log.F("error", err), + log.F("duration", duration)) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } - logger.LogDebug("Entity created successfully", - logger.F("duration", duration)) + log.LogDebug("Entity created successfully", + log.F("duration", duration)) return nil } @@ -226,14 +226,14 @@ func (r *BaseRepositoryImpl[T]) CreateInTx(ctx context.Context, tx *gorm.DB, ent duration := time.Since(start) if err != nil { - logger.LogError("Failed to create entity in transaction", - logger.F("error", err), - logger.F("duration", duration)) + log.LogError("Failed to create entity in transaction", + log.F("error", err), + log.F("duration", duration)) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } - logger.LogDebug("Entity created successfully in transaction", - logger.F("duration", duration)) + log.LogDebug("Entity created successfully in transaction", + log.F("duration", duration)) return nil } @@ -253,21 +253,21 @@ func (r *BaseRepositoryImpl[T]) GetByID(ctx context.Context, id uint) (*T, error if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - logger.LogDebug("Entity not found", - logger.F("id", id), - logger.F("duration", duration)) + log.LogDebug("Entity not found", + log.F("id", id), + log.F("duration", duration)) return nil, ErrEntityNotFound } - logger.LogError("Failed to get entity by ID", - logger.F("id", id), - logger.F("error", err), - logger.F("duration", duration)) + log.LogError("Failed to get entity by ID", + log.F("id", id), + log.F("error", err), + log.F("duration", duration)) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } - logger.LogDebug("Entity retrieved successfully", - logger.F("id", id), - logger.F("duration", duration)) + log.LogDebug("Entity retrieved successfully", + log.F("id", id), + log.F("duration", duration)) return &entity, nil } @@ -288,21 +288,21 @@ func (r *BaseRepositoryImpl[T]) GetByIDWithOptions(ctx context.Context, id uint, if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - logger.LogDebug("Entity not found with options", - logger.F("id", id), - logger.F("duration", duration)) + log.LogDebug("Entity not found with options", + log.F("id", id), + log.F("duration", duration)) return nil, ErrEntityNotFound } - logger.LogError("Failed to get entity by ID with options", - logger.F("id", id), - logger.F("error", err), - logger.F("duration", duration)) + log.LogError("Failed to get entity by ID with options", + log.F("id", id), + log.F("error", err), + log.F("duration", duration)) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } - logger.LogDebug("Entity retrieved successfully with options", - logger.F("id", id), - logger.F("duration", duration)) + log.LogDebug("Entity retrieved successfully with options", + log.F("id", id), + log.F("duration", duration)) return &entity, nil } @@ -320,14 +320,14 @@ func (r *BaseRepositoryImpl[T]) Update(ctx context.Context, entity *T) error { duration := time.Since(start) if err != nil { - logger.LogError("Failed to update entity", - logger.F("error", err), - logger.F("duration", duration)) + log.LogError("Failed to update entity", + log.F("error", err), + log.F("duration", duration)) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } - logger.LogDebug("Entity updated successfully", - logger.F("duration", duration)) + log.LogDebug("Entity updated successfully", + log.F("duration", duration)) return nil } @@ -348,14 +348,14 @@ func (r *BaseRepositoryImpl[T]) UpdateInTx(ctx context.Context, tx *gorm.DB, ent duration := time.Since(start) if err != nil { - logger.LogError("Failed to update entity in transaction", - logger.F("error", err), - logger.F("duration", duration)) + log.LogError("Failed to update entity in transaction", + log.F("error", err), + log.F("duration", duration)) return fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } - logger.LogDebug("Entity updated successfully in transaction", - logger.F("duration", duration)) + log.LogDebug("Entity updated successfully in transaction", + log.F("duration", duration)) return nil } @@ -374,24 +374,24 @@ func (r *BaseRepositoryImpl[T]) Delete(ctx context.Context, id uint) error { duration := time.Since(start) if result.Error != nil { - logger.LogError("Failed to delete entity", - logger.F("id", id), - logger.F("error", result.Error), - logger.F("duration", duration)) + log.LogError("Failed to delete entity", + log.F("id", id), + log.F("error", result.Error), + log.F("duration", duration)) return fmt.Errorf("%w: %v", ErrDatabaseOperation, result.Error) } if result.RowsAffected == 0 { - logger.LogDebug("No entity found to delete", - logger.F("id", id), - logger.F("duration", duration)) + log.LogDebug("No entity found to delete", + log.F("id", id), + log.F("duration", duration)) return ErrEntityNotFound } - logger.LogDebug("Entity deleted successfully", - logger.F("id", id), - logger.F("rowsAffected", result.RowsAffected), - logger.F("duration", duration)) + log.LogDebug("Entity deleted successfully", + log.F("id", id), + log.F("rowsAffected", result.RowsAffected), + log.F("duration", duration)) return nil } @@ -413,24 +413,24 @@ func (r *BaseRepositoryImpl[T]) DeleteInTx(ctx context.Context, tx *gorm.DB, id duration := time.Since(start) if result.Error != nil { - logger.LogError("Failed to delete entity in transaction", - logger.F("id", id), - logger.F("error", result.Error), - logger.F("duration", duration)) + log.LogError("Failed to delete entity in transaction", + log.F("id", id), + log.F("error", result.Error), + log.F("duration", duration)) return fmt.Errorf("%w: %v", ErrDatabaseOperation, result.Error) } if result.RowsAffected == 0 { - logger.LogDebug("No entity found to delete in transaction", - logger.F("id", id), - logger.F("duration", duration)) + log.LogDebug("No entity found to delete in transaction", + log.F("id", id), + log.F("duration", duration)) return ErrEntityNotFound } - logger.LogDebug("Entity deleted successfully in transaction", - logger.F("id", id), - logger.F("rowsAffected", result.RowsAffected), - logger.F("duration", duration)) + log.LogDebug("Entity deleted successfully in transaction", + log.F("id", id), + log.F("rowsAffected", result.RowsAffected), + log.F("duration", duration)) return nil } @@ -451,9 +451,9 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (* // Get total count if err := r.db.WithContext(ctx).Model(new(T)).Count(&totalCount).Error; err != nil { - logger.LogError("Failed to count entities", - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to count entities", + log.F("error", err), + log.F("duration", time.Since(start))) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } @@ -462,11 +462,11 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (* // Get paginated data if err := r.db.WithContext(ctx).Offset(offset).Limit(pageSize).Find(&entities).Error; err != nil { - logger.LogError("Failed to get paginated entities", - logger.F("page", page), - logger.F("pageSize", pageSize), - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to get paginated entities", + log.F("page", page), + log.F("pageSize", pageSize), + log.F("error", err), + log.F("duration", time.Since(start))) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } @@ -481,14 +481,14 @@ func (r *BaseRepositoryImpl[T]) List(ctx context.Context, page, pageSize int) (* hasNext := page < totalPages hasPrev := page > 1 - logger.LogDebug("Paginated entities retrieved successfully", - logger.F("page", page), - logger.F("pageSize", pageSize), - logger.F("totalCount", totalCount), - logger.F("totalPages", totalPages), - logger.F("hasNext", hasNext), - logger.F("hasPrev", hasPrev), - logger.F("duration", duration)) + log.LogDebug("Paginated entities retrieved successfully", + log.F("page", page), + log.F("pageSize", pageSize), + log.F("totalCount", totalCount), + log.F("totalPages", totalPages), + log.F("hasNext", hasNext), + log.F("hasPrev", hasPrev), + log.F("duration", duration)) return &PaginatedResult[T]{ Items: entities, @@ -512,16 +512,16 @@ func (r *BaseRepositoryImpl[T]) ListWithOptions(ctx context.Context, options *Qu query := r.buildQuery(r.db.WithContext(ctx), options) if err := query.Find(&entities).Error; err != nil { - logger.LogError("Failed to get entities with options", - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to get entities with options", + log.F("error", err), + log.F("duration", time.Since(start))) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } duration := time.Since(start) - logger.LogDebug("Entities retrieved successfully with options", - logger.F("count", len(entities)), - logger.F("duration", duration)) + log.LogDebug("Entities retrieved successfully with options", + log.F("count", len(entities)), + log.F("duration", duration)) return entities, nil } @@ -535,16 +535,16 @@ func (r *BaseRepositoryImpl[T]) ListAll(ctx context.Context) ([]T, error) { start := time.Now() var entities []T if err := r.db.WithContext(ctx).Find(&entities).Error; err != nil { - logger.LogError("Failed to get all entities", - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to get all entities", + log.F("error", err), + log.F("duration", time.Since(start))) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } duration := time.Since(start) - logger.LogDebug("All entities retrieved successfully", - logger.F("count", len(entities)), - logger.F("duration", duration)) + log.LogDebug("All entities retrieved successfully", + log.F("count", len(entities)), + log.F("duration", duration)) return entities, nil } @@ -558,16 +558,16 @@ func (r *BaseRepositoryImpl[T]) Count(ctx context.Context) (int64, error) { start := time.Now() var count int64 if err := r.db.WithContext(ctx).Model(new(T)).Count(&count).Error; err != nil { - logger.LogError("Failed to count entities", - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to count entities", + log.F("error", err), + log.F("duration", time.Since(start))) return 0, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } duration := time.Since(start) - logger.LogDebug("Entity count retrieved successfully", - logger.F("count", count), - logger.F("duration", duration)) + log.LogDebug("Entity count retrieved successfully", + log.F("count", count), + log.F("duration", duration)) return count, nil } @@ -583,16 +583,16 @@ func (r *BaseRepositoryImpl[T]) CountWithOptions(ctx context.Context, options *Q query := r.buildQuery(r.db.WithContext(ctx), options) if err := query.Model(new(T)).Count(&count).Error; err != nil { - logger.LogError("Failed to count entities with options", - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to count entities with options", + log.F("error", err), + log.F("duration", time.Since(start))) return 0, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } duration := time.Since(start) - logger.LogDebug("Entity count retrieved successfully with options", - logger.F("count", count), - logger.F("duration", duration)) + log.LogDebug("Entity count retrieved successfully with options", + log.F("count", count), + log.F("duration", duration)) return count, nil } @@ -616,25 +616,25 @@ func (r *BaseRepositoryImpl[T]) FindWithPreload(ctx context.Context, preloads [] if err := query.First(&entity, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - logger.LogDebug("Entity not found with preloads", - logger.F("id", id), - logger.F("preloads", preloads), - logger.F("duration", time.Since(start))) + log.LogDebug("Entity not found with preloads", + log.F("id", id), + log.F("preloads", preloads), + log.F("duration", time.Since(start))) return nil, ErrEntityNotFound } - logger.LogError("Failed to get entity with preloads", - logger.F("id", id), - logger.F("preloads", preloads), - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to get entity with preloads", + log.F("id", id), + log.F("preloads", preloads), + log.F("error", err), + log.F("duration", time.Since(start))) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } duration := time.Since(start) - logger.LogDebug("Entity retrieved successfully with preloads", - logger.F("id", id), - logger.F("preloads", preloads), - logger.F("duration", duration)) + log.LogDebug("Entity retrieved successfully with preloads", + log.F("id", id), + log.F("preloads", preloads), + log.F("duration", duration)) return &entity, nil } @@ -659,20 +659,20 @@ func (r *BaseRepositoryImpl[T]) GetAllForSync(ctx context.Context, batchSize, of start := time.Now() var entities []T if err := r.db.WithContext(ctx).Offset(offset).Limit(batchSize).Find(&entities).Error; err != nil { - logger.LogError("Failed to get entities for sync", - logger.F("batchSize", batchSize), - logger.F("offset", offset), - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to get entities for sync", + log.F("batchSize", batchSize), + log.F("offset", offset), + log.F("error", err), + log.F("duration", time.Since(start))) return nil, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } duration := time.Since(start) - logger.LogDebug("Entities retrieved successfully for sync", - logger.F("batchSize", batchSize), - logger.F("offset", offset), - logger.F("count", len(entities)), - logger.F("duration", duration)) + log.LogDebug("Entities retrieved successfully for sync", + log.F("batchSize", batchSize), + log.F("offset", offset), + log.F("count", len(entities)), + log.F("duration", duration)) return entities, nil } @@ -689,20 +689,20 @@ func (r *BaseRepositoryImpl[T]) Exists(ctx context.Context, id uint) (bool, erro start := time.Now() var count int64 if err := r.db.WithContext(ctx).Model(new(T)).Where("id = ?", id).Count(&count).Error; err != nil { - logger.LogError("Failed to check entity existence", - logger.F("id", id), - logger.F("error", err), - logger.F("duration", time.Since(start))) + log.LogError("Failed to check entity existence", + log.F("id", id), + log.F("error", err), + log.F("duration", time.Since(start))) return false, fmt.Errorf("%w: %v", ErrDatabaseOperation, err) } duration := time.Since(start) exists := count > 0 - logger.LogDebug("Entity existence checked", - logger.F("id", id), - logger.F("exists", exists), - logger.F("duration", duration)) + log.LogDebug("Entity existence checked", + log.F("id", id), + log.F("exists", exists), + log.F("duration", duration)) return exists, nil } @@ -715,12 +715,12 @@ func (r *BaseRepositoryImpl[T]) BeginTx(ctx context.Context) (*gorm.DB, error) { tx := r.db.WithContext(ctx).Begin() if tx.Error != nil { - logger.LogError("Failed to begin transaction", - logger.F("error", tx.Error)) + log.LogError("Failed to begin transaction", + log.F("error", tx.Error)) return nil, fmt.Errorf("%w: %v", ErrTransactionFailed, tx.Error) } - logger.LogDebug("Transaction started successfully") + log.LogDebug("Transaction started successfully") return tx, nil } @@ -738,29 +738,29 @@ func (r *BaseRepositoryImpl[T]) WithTx(ctx context.Context, fn func(tx *gorm.DB) defer func() { if r := recover(); r != nil { tx.Rollback() - logger.LogError("Transaction panic recovered", - logger.F("panic", r)) + log.LogError("Transaction panic recovered", + log.F("panic", r)) } }() if err := fn(tx); err != nil { if rbErr := tx.Rollback().Error; rbErr != nil { - logger.LogError("Failed to rollback transaction", - logger.F("originalError", err), - logger.F("rollbackError", rbErr)) + log.LogError("Failed to rollback transaction", + log.F("originalError", err), + log.F("rollbackError", rbErr)) return fmt.Errorf("transaction failed and rollback failed: %v (rollback: %v)", err, rbErr) } - logger.LogDebug("Transaction rolled back due to error", - logger.F("error", err)) + log.LogDebug("Transaction rolled back due to error", + log.F("error", err)) return err } if err := tx.Commit().Error; err != nil { - logger.LogError("Failed to commit transaction", - logger.F("error", err)) + log.LogError("Failed to commit transaction", + log.F("error", err)) return fmt.Errorf("%w: %v", ErrTransactionFailed, err) } - logger.LogDebug("Transaction committed successfully") + log.LogDebug("Transaction committed successfully") return nil } diff --git a/repositories/book_repository.go b/internal/repositories/book_repository.go similarity index 98% rename from repositories/book_repository.go rename to internal/repositories/book_repository.go index 0536139..2c14040 100644 --- a/repositories/book_repository.go +++ b/internal/repositories/book_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // BookRepository defines CRUD methods specific to Book. diff --git a/repositories/bookmark_repository.go b/internal/repositories/bookmark_repository.go similarity index 98% rename from repositories/bookmark_repository.go rename to internal/repositories/bookmark_repository.go index 2f3cae8..47fb485 100644 --- a/repositories/bookmark_repository.go +++ b/internal/repositories/bookmark_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // BookmarkRepository defines CRUD methods specific to Bookmark. diff --git a/repositories/cached_repository.go b/internal/repositories/cached_repository.go similarity index 80% rename from repositories/cached_repository.go rename to internal/repositories/cached_repository.go index 3abbaaf..825915c 100644 --- a/repositories/cached_repository.go +++ b/internal/repositories/cached_repository.go @@ -6,9 +6,8 @@ import ( "time" "gorm.io/gorm" - - "tercul/cache" - "tercul/logger" + "tercul/internal/platform/cache" + "tercul/internal/platform/log" ) // simpleKeyGenerator implements the cache.KeyGenerator interface @@ -37,8 +36,8 @@ func (g *simpleKeyGenerator) QueryKey(entityType string, queryName string, param // CachedRepository wraps a BaseRepository with caching functionality type CachedRepository[T any] struct { - repo BaseRepository[T] - cache cache.Cache + repo BaseRepository[T] + cache cache.Cache keyGenerator cache.KeyGenerator entityType string cacheExpiry time.Duration @@ -93,9 +92,9 @@ func (r *CachedRepository[T]) Create(ctx context.Context, entity *T) error { if r.cacheEnabled { if redisCache, ok := r.cache.(*cache.RedisCache); ok { if err := redisCache.InvalidateEntityType(ctx, r.entityType); err != nil { - logger.LogWarn("Failed to invalidate cache", - logger.F("entityType", r.entityType), - logger.F("error", err)) + log.LogWarn("Failed to invalidate cache", + log.F("entityType", r.entityType), + log.F("error", err)) } } } @@ -120,16 +119,16 @@ func (r *CachedRepository[T]) GetByID(ctx context.Context, id uint) (*T, error) err := r.cache.Get(ctx, cacheKey, &entity) if err == nil { // Cache hit - logger.LogDebug("Cache hit", - logger.F("entityType", r.entityType), - logger.F("id", id)) + log.LogDebug("Cache hit", + log.F("entityType", r.entityType), + log.F("id", id)) return &entity, nil } // Cache miss, get from database - logger.LogDebug("Cache miss", - logger.F("entityType", r.entityType), - logger.F("id", id)) + log.LogDebug("Cache miss", + log.F("entityType", r.entityType), + log.F("id", id)) entity_ptr, err := r.repo.GetByID(ctx, id) if err != nil { @@ -138,10 +137,10 @@ func (r *CachedRepository[T]) GetByID(ctx context.Context, id uint) (*T, error) // Store in cache if err := r.cache.Set(ctx, cacheKey, entity_ptr, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache entity", - logger.F("entityType", r.entityType), - logger.F("id", id), - logger.F("error", err)) + log.LogWarn("Failed to cache entity", + log.F("entityType", r.entityType), + log.F("id", id), + log.F("error", err)) } return entity_ptr, nil @@ -165,17 +164,17 @@ func (r *CachedRepository[T]) Update(ctx context.Context, entity *T) error { // Invalidate specific entity cache cacheKey := r.keyGenerator.EntityKey(r.entityType, 0) // We don't have ID here, so invalidate all if err := r.cache.Delete(ctx, cacheKey); err != nil { - logger.LogWarn("Failed to invalidate entity cache", - logger.F("entityType", r.entityType), - logger.F("error", err)) + log.LogWarn("Failed to invalidate entity cache", + log.F("entityType", r.entityType), + log.F("error", err)) } // Invalidate list caches if redisCache, ok := r.cache.(*cache.RedisCache); ok { if err := redisCache.InvalidateEntityType(ctx, r.entityType); err != nil { - logger.LogWarn("Failed to invalidate cache", - logger.F("entityType", r.entityType), - logger.F("error", err)) + log.LogWarn("Failed to invalidate cache", + log.F("entityType", r.entityType), + log.F("error", err)) } } } @@ -199,18 +198,18 @@ func (r *CachedRepository[T]) Delete(ctx context.Context, id uint) error { if r.cacheEnabled { cacheKey := r.keyGenerator.EntityKey(r.entityType, id) if err := r.cache.Delete(ctx, cacheKey); err != nil { - logger.LogWarn("Failed to invalidate entity cache", - logger.F("entityType", r.entityType), - logger.F("id", id), - logger.F("error", err)) + log.LogWarn("Failed to invalidate entity cache", + log.F("entityType", r.entityType), + log.F("id", id), + log.F("error", err)) } // Invalidate list caches if redisCache, ok := r.cache.(*cache.RedisCache); ok { if err := redisCache.InvalidateEntityType(ctx, r.entityType); err != nil { - logger.LogWarn("Failed to invalidate cache", - logger.F("entityType", r.entityType), - logger.F("error", err)) + log.LogWarn("Failed to invalidate cache", + log.F("entityType", r.entityType), + log.F("error", err)) } } } @@ -235,18 +234,18 @@ func (r *CachedRepository[T]) List(ctx context.Context, page, pageSize int) (*Pa err := r.cache.Get(ctx, cacheKey, &result) if err == nil { // Cache hit - logger.LogDebug("Cache hit for list", - logger.F("entityType", r.entityType), - logger.F("page", page), - logger.F("pageSize", pageSize)) + log.LogDebug("Cache hit for list", + log.F("entityType", r.entityType), + log.F("page", page), + log.F("pageSize", pageSize)) return &result, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for list", - logger.F("entityType", r.entityType), - logger.F("page", page), - logger.F("pageSize", pageSize)) + log.LogDebug("Cache miss for list", + log.F("entityType", r.entityType), + log.F("page", page), + log.F("pageSize", pageSize)) result_ptr, err := r.repo.List(ctx, page, pageSize) if err != nil { @@ -255,11 +254,11 @@ func (r *CachedRepository[T]) List(ctx context.Context, page, pageSize int) (*Pa // Store in cache if err := r.cache.Set(ctx, cacheKey, result_ptr, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache list", - logger.F("entityType", r.entityType), - logger.F("page", page), - logger.F("pageSize", pageSize), - logger.F("error", err)) + log.LogWarn("Failed to cache list", + log.F("entityType", r.entityType), + log.F("page", page), + log.F("pageSize", pageSize), + log.F("error", err)) } return result_ptr, nil @@ -283,14 +282,14 @@ func (r *CachedRepository[T]) ListAll(ctx context.Context) ([]T, error) { err := r.cache.Get(ctx, cacheKey, &entities) if err == nil { // Cache hit - logger.LogDebug("Cache hit for listAll", - logger.F("entityType", r.entityType)) + log.LogDebug("Cache hit for listAll", + log.F("entityType", r.entityType)) return entities, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for listAll", - logger.F("entityType", r.entityType)) + log.LogDebug("Cache miss for listAll", + log.F("entityType", r.entityType)) entities, err = r.repo.ListAll(ctx) if err != nil { @@ -299,9 +298,9 @@ func (r *CachedRepository[T]) ListAll(ctx context.Context) ([]T, error) { // Store in cache if err := r.cache.Set(ctx, cacheKey, entities, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache listAll", - logger.F("entityType", r.entityType), - logger.F("error", err)) + log.LogWarn("Failed to cache listAll", + log.F("entityType", r.entityType), + log.F("error", err)) } return entities, nil @@ -319,14 +318,14 @@ func (r *CachedRepository[T]) Count(ctx context.Context) (int64, error) { err := r.cache.Get(ctx, cacheKey, &count) if err == nil { // Cache hit - logger.LogDebug("Cache hit for count", - logger.F("entityType", r.entityType)) + log.LogDebug("Cache hit for count", + log.F("entityType", r.entityType)) return count, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for count", - logger.F("entityType", r.entityType)) + log.LogDebug("Cache miss for count", + log.F("entityType", r.entityType)) count, err = r.repo.Count(ctx) if err != nil { @@ -335,9 +334,9 @@ func (r *CachedRepository[T]) Count(ctx context.Context) (int64, error) { // Store in cache if err := r.cache.Set(ctx, cacheKey, count, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache count", - logger.F("entityType", r.entityType), - logger.F("error", err)) + log.LogWarn("Failed to cache count", + log.F("entityType", r.entityType), + log.F("error", err)) } return count, nil diff --git a/repositories/cached_repository_test.go b/internal/repositories/cached_repository_test.go similarity index 89% rename from repositories/cached_repository_test.go rename to internal/repositories/cached_repository_test.go index 03bb8f1..e2f1c8b 100644 --- a/repositories/cached_repository_test.go +++ b/internal/repositories/cached_repository_test.go @@ -3,16 +3,15 @@ package repositories_test import ( "context" "errors" + "tercul/internal/models" + repositories2 "tercul/internal/repositories" "testing" "time" - "tercul/internal/testutil" - "tercul/models" - "tercul/repositories" - "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" "gorm.io/gorm" + "tercul/internal/testutil" ) // TestModel is a simple entity used for cached repository tests @@ -77,7 +76,7 @@ func (m *MockRepository[T]) GetByID(ctx context.Context, id uint) (*T, error) { return args.Get(0).(*T), args.Error(1) } -func (m *MockRepository[T]) GetByIDWithOptions(ctx context.Context, id uint, options *repositories.QueryOptions) (*T, error) { +func (m *MockRepository[T]) GetByIDWithOptions(ctx context.Context, id uint, options *repositories2.QueryOptions) (*T, error) { return nil, nil } @@ -95,15 +94,15 @@ func (m *MockRepository[T]) Delete(ctx context.Context, id uint) error { func (m *MockRepository[T]) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { return nil } -func (m *MockRepository[T]) List(ctx context.Context, page, pageSize int) (*repositories.PaginatedResult[T], error) { +func (m *MockRepository[T]) List(ctx context.Context, page, pageSize int) (*repositories2.PaginatedResult[T], error) { args := m.Called(ctx, page, pageSize) if args.Get(0) == nil { return nil, args.Error(1) } - return args.Get(0).(*repositories.PaginatedResult[T]), args.Error(1) + return args.Get(0).(*repositories2.PaginatedResult[T]), args.Error(1) } -func (m *MockRepository[T]) ListWithOptions(ctx context.Context, options *repositories.QueryOptions) ([]T, error) { +func (m *MockRepository[T]) ListWithOptions(ctx context.Context, options *repositories2.QueryOptions) ([]T, error) { var z []T return z, nil } @@ -129,7 +128,7 @@ func (m *MockRepository[T]) Count(ctx context.Context) (int64, error) { return args.Get(0).(int64), args.Error(1) } -func (m *MockRepository[T]) CountWithOptions(ctx context.Context, options *repositories.QueryOptions) (int64, error) { +func (m *MockRepository[T]) CountWithOptions(ctx context.Context, options *repositories2.QueryOptions) (int64, error) { return 0, nil } @@ -141,8 +140,8 @@ func (m *MockRepository[T]) FindWithPreload(ctx context.Context, preloads []stri return args.Get(0).(*T), args.Error(1) } -func (m *MockRepository[T]) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } -func (m *MockRepository[T]) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } +func (m *MockRepository[T]) Exists(ctx context.Context, id uint) (bool, error) { return false, nil } +func (m *MockRepository[T]) BeginTx(ctx context.Context) (*gorm.DB, error) { return nil, nil } func (m *MockRepository[T]) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { return nil } // CachedRepositorySuite is a test suite for the CachedRepository @@ -150,14 +149,14 @@ type CachedRepositorySuite struct { testutil.BaseSuite mockRepo *MockRepository[TestModel] mockCache *MockCache - repo *repositories.CachedRepository[TestModel] + repo *repositories2.CachedRepository[TestModel] } // SetupTest sets up each test func (s *CachedRepositorySuite) SetupTest() { s.mockRepo = new(MockRepository[TestModel]) s.mockCache = new(MockCache) - s.repo = repositories.NewCachedRepository[TestModel]( + s.repo = repositories2.NewCachedRepository[TestModel]( s.mockRepo, s.mockCache, nil, @@ -279,20 +278,20 @@ func (s *CachedRepositorySuite) TestUpdate() { } // Mock repository - s.mockRepo.On("Update", mock.Anything, model). + s.mockRepo.On("Update", mock.Anything, model). Return(nil) - // Execute - ctx := context.Background() - // Expect cache delete during update invalidation - s.mockCache.On("Delete", mock.Anything, mock.Anything).Return(nil) - err := s.repo.Update(ctx, model) + // Execute + ctx := context.Background() + // Expect cache delete during update invalidation + s.mockCache.On("Delete", mock.Anything, mock.Anything).Return(nil) + err := s.repo.Update(ctx, model) // Assert s.Require().NoError(err) // Verify mocks - s.mockRepo.AssertCalled(s.T(), "Update", mock.Anything, model) + s.mockRepo.AssertCalled(s.T(), "Update", mock.Anything, model) } // TestDelete tests the Delete method @@ -300,9 +299,9 @@ func (s *CachedRepositorySuite) TestDelete() { // Setup id := uint(1) - // Mock repository and cache delete - s.mockRepo.On("Delete", mock.Anything, id).Return(nil) - s.mockCache.On("Delete", mock.Anything, mock.Anything).Return(nil) + // Mock repository and cache delete + s.mockRepo.On("Delete", mock.Anything, id).Return(nil) + s.mockCache.On("Delete", mock.Anything, mock.Anything).Return(nil) // Execute ctx := context.Background() @@ -320,7 +319,7 @@ func (s *CachedRepositorySuite) TestListCacheHit() { // Setup page := 1 pageSize := 10 - expectedResult := &repositories.PaginatedResult[TestModel]{ + expectedResult := &repositories2.PaginatedResult[TestModel]{ Items: []TestModel{ { BaseModel: models.BaseModel{ @@ -347,7 +346,7 @@ func (s *CachedRepositorySuite) TestListCacheHit() { s.mockCache.On("Get", mock.Anything, mock.Anything, mock.Anything). Run(func(args mock.Arguments) { // Set the value to simulate cache hit - value := args.Get(2).(*repositories.PaginatedResult[TestModel]) + value := args.Get(2).(*repositories2.PaginatedResult[TestModel]) *value = *expectedResult }). Return(nil) @@ -375,7 +374,7 @@ func (s *CachedRepositorySuite) TestListCacheMiss() { // Setup page := 1 pageSize := 10 - expectedResult := &repositories.PaginatedResult[TestModel]{ + expectedResult := &repositories2.PaginatedResult[TestModel]{ Items: []TestModel{ { BaseModel: models.BaseModel{ diff --git a/repositories/cached_work_repository.go b/internal/repositories/cached_work_repository.go similarity index 65% rename from repositories/cached_work_repository.go rename to internal/repositories/cached_work_repository.go index f24aa91..1684e9d 100644 --- a/repositories/cached_work_repository.go +++ b/internal/repositories/cached_work_repository.go @@ -2,11 +2,11 @@ package repositories import ( "context" + "tercul/internal/models" "time" - "tercul/cache" - "tercul/logger" - "tercul/models" + "tercul/internal/platform/cache" + "tercul/internal/platform/log" ) // CachedWorkRepository wraps a WorkRepository with caching functionality @@ -54,16 +54,16 @@ func (r *CachedWorkRepository) FindByTitle(ctx context.Context, title string) ([ err := r.cache.Get(ctx, cacheKey, &result) if err == nil { // Cache hit - logger.LogDebug("Cache hit for FindByTitle", - logger.F("entityType", r.entityType), - logger.F("title", title)) + log.LogDebug("Cache hit for FindByTitle", + log.F("entityType", r.entityType), + log.F("title", title)) return result, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for FindByTitle", - logger.F("entityType", r.entityType), - logger.F("title", title)) + log.LogDebug("Cache miss for FindByTitle", + log.F("entityType", r.entityType), + log.F("title", title)) result, err = r.workRepo.FindByTitle(ctx, title) if err != nil { @@ -72,10 +72,10 @@ func (r *CachedWorkRepository) FindByTitle(ctx context.Context, title string) ([ // Store in cache if err := r.cache.Set(ctx, cacheKey, result, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache FindByTitle result", - logger.F("entityType", r.entityType), - logger.F("title", title), - logger.F("error", err)) + log.LogWarn("Failed to cache FindByTitle result", + log.F("entityType", r.entityType), + log.F("title", title), + log.F("error", err)) } return result, nil @@ -93,16 +93,16 @@ func (r *CachedWorkRepository) FindByAuthor(ctx context.Context, authorID uint) err := r.cache.Get(ctx, cacheKey, &result) if err == nil { // Cache hit - logger.LogDebug("Cache hit for FindByAuthor", - logger.F("entityType", r.entityType), - logger.F("authorID", authorID)) + log.LogDebug("Cache hit for FindByAuthor", + log.F("entityType", r.entityType), + log.F("authorID", authorID)) return result, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for FindByAuthor", - logger.F("entityType", r.entityType), - logger.F("authorID", authorID)) + log.LogDebug("Cache miss for FindByAuthor", + log.F("entityType", r.entityType), + log.F("authorID", authorID)) result, err = r.workRepo.FindByAuthor(ctx, authorID) if err != nil { @@ -111,10 +111,10 @@ func (r *CachedWorkRepository) FindByAuthor(ctx context.Context, authorID uint) // Store in cache if err := r.cache.Set(ctx, cacheKey, result, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache FindByAuthor result", - logger.F("entityType", r.entityType), - logger.F("authorID", authorID), - logger.F("error", err)) + log.LogWarn("Failed to cache FindByAuthor result", + log.F("entityType", r.entityType), + log.F("authorID", authorID), + log.F("error", err)) } return result, nil @@ -132,16 +132,16 @@ func (r *CachedWorkRepository) FindByCategory(ctx context.Context, categoryID ui err := r.cache.Get(ctx, cacheKey, &result) if err == nil { // Cache hit - logger.LogDebug("Cache hit for FindByCategory", - logger.F("entityType", r.entityType), - logger.F("categoryID", categoryID)) + log.LogDebug("Cache hit for FindByCategory", + log.F("entityType", r.entityType), + log.F("categoryID", categoryID)) return result, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for FindByCategory", - logger.F("entityType", r.entityType), - logger.F("categoryID", categoryID)) + log.LogDebug("Cache miss for FindByCategory", + log.F("entityType", r.entityType), + log.F("categoryID", categoryID)) result, err = r.workRepo.FindByCategory(ctx, categoryID) if err != nil { @@ -150,10 +150,10 @@ func (r *CachedWorkRepository) FindByCategory(ctx context.Context, categoryID ui // Store in cache if err := r.cache.Set(ctx, cacheKey, result, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache FindByCategory result", - logger.F("entityType", r.entityType), - logger.F("categoryID", categoryID), - logger.F("error", err)) + log.LogWarn("Failed to cache FindByCategory result", + log.F("entityType", r.entityType), + log.F("categoryID", categoryID), + log.F("error", err)) } return result, nil @@ -171,20 +171,20 @@ func (r *CachedWorkRepository) FindByLanguage(ctx context.Context, language stri err := r.cache.Get(ctx, cacheKey, &result) if err == nil { // Cache hit - logger.LogDebug("Cache hit for FindByLanguage", - logger.F("entityType", r.entityType), - logger.F("language", language), - logger.F("page", page), - logger.F("pageSize", pageSize)) + log.LogDebug("Cache hit for FindByLanguage", + log.F("entityType", r.entityType), + log.F("language", language), + log.F("page", page), + log.F("pageSize", pageSize)) return &result, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for FindByLanguage", - logger.F("entityType", r.entityType), - logger.F("language", language), - logger.F("page", page), - logger.F("pageSize", pageSize)) + log.LogDebug("Cache miss for FindByLanguage", + log.F("entityType", r.entityType), + log.F("language", language), + log.F("page", page), + log.F("pageSize", pageSize)) result_ptr, err := r.workRepo.FindByLanguage(ctx, language, page, pageSize) if err != nil { @@ -193,12 +193,12 @@ func (r *CachedWorkRepository) FindByLanguage(ctx context.Context, language stri // Store in cache if err := r.cache.Set(ctx, cacheKey, result_ptr, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache FindByLanguage result", - logger.F("entityType", r.entityType), - logger.F("language", language), - logger.F("page", page), - logger.F("pageSize", pageSize), - logger.F("error", err)) + log.LogWarn("Failed to cache FindByLanguage result", + log.F("entityType", r.entityType), + log.F("language", language), + log.F("page", page), + log.F("pageSize", pageSize), + log.F("error", err)) } return result_ptr, nil @@ -216,16 +216,16 @@ func (r *CachedWorkRepository) GetWithTranslations(ctx context.Context, id uint) err := r.cache.Get(ctx, cacheKey, &result) if err == nil { // Cache hit - logger.LogDebug("Cache hit for GetWithTranslations", - logger.F("entityType", r.entityType), - logger.F("id", id)) + log.LogDebug("Cache hit for GetWithTranslations", + log.F("entityType", r.entityType), + log.F("id", id)) return &result, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for GetWithTranslations", - logger.F("entityType", r.entityType), - logger.F("id", id)) + log.LogDebug("Cache miss for GetWithTranslations", + log.F("entityType", r.entityType), + log.F("id", id)) result_ptr, err := r.workRepo.GetWithTranslations(ctx, id) if err != nil { @@ -234,10 +234,10 @@ func (r *CachedWorkRepository) GetWithTranslations(ctx context.Context, id uint) // Store in cache if err := r.cache.Set(ctx, cacheKey, result_ptr, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache GetWithTranslations result", - logger.F("entityType", r.entityType), - logger.F("id", id), - logger.F("error", err)) + log.LogWarn("Failed to cache GetWithTranslations result", + log.F("entityType", r.entityType), + log.F("id", id), + log.F("error", err)) } return result_ptr, nil @@ -255,18 +255,18 @@ func (r *CachedWorkRepository) ListWithTranslations(ctx context.Context, page, p err := r.cache.Get(ctx, cacheKey, &result) if err == nil { // Cache hit - logger.LogDebug("Cache hit for ListWithTranslations", - logger.F("entityType", r.entityType), - logger.F("page", page), - logger.F("pageSize", pageSize)) + log.LogDebug("Cache hit for ListWithTranslations", + log.F("entityType", r.entityType), + log.F("page", page), + log.F("pageSize", pageSize)) return &result, nil } // Cache miss, get from database - logger.LogDebug("Cache miss for ListWithTranslations", - logger.F("entityType", r.entityType), - logger.F("page", page), - logger.F("pageSize", pageSize)) + log.LogDebug("Cache miss for ListWithTranslations", + log.F("entityType", r.entityType), + log.F("page", page), + log.F("pageSize", pageSize)) result_ptr, err := r.workRepo.ListWithTranslations(ctx, page, pageSize) if err != nil { @@ -275,11 +275,11 @@ func (r *CachedWorkRepository) ListWithTranslations(ctx context.Context, page, p // Store in cache if err := r.cache.Set(ctx, cacheKey, result_ptr, r.cacheExpiry); err != nil { - logger.LogWarn("Failed to cache ListWithTranslations result", - logger.F("entityType", r.entityType), - logger.F("page", page), - logger.F("pageSize", pageSize), - logger.F("error", err)) + log.LogWarn("Failed to cache ListWithTranslations result", + log.F("entityType", r.entityType), + log.F("page", page), + log.F("pageSize", pageSize), + log.F("error", err)) } return result_ptr, nil diff --git a/repositories/cached_work_repository_test.go b/internal/repositories/cached_work_repository_test.go similarity index 80% rename from repositories/cached_work_repository_test.go rename to internal/repositories/cached_work_repository_test.go index 08f7ece..377706a 100644 --- a/repositories/cached_work_repository_test.go +++ b/internal/repositories/cached_work_repository_test.go @@ -4,14 +4,13 @@ import ( "context" "encoding/json" "errors" + models2 "tercul/internal/models" + repositories2 "tercul/internal/repositories" "testing" "time" - "tercul/internal/testutil" - "tercul/models" - "tercul/repositories" - "github.com/stretchr/testify/suite" + "tercul/internal/testutil" ) // ErrCacheMiss is returned when a key is not found in the cache @@ -84,19 +83,19 @@ func (c *testCache) SetMulti(ctx context.Context, items map[string]interface{}, // MockWorkRepository for testing type MockWorkRepository struct { - works []*models.Work + works []*models2.Work } func NewMockWorkRepository() *MockWorkRepository { - return &MockWorkRepository{works: []*models.Work{}} + return &MockWorkRepository{works: []*models2.Work{}} } -func (m *MockWorkRepository) AddWork(work *models.Work) { +func (m *MockWorkRepository) AddWork(work *models2.Work) { work.ID = uint(len(m.works) + 1) m.works = append(m.works, work) } -func (m *MockWorkRepository) GetByID(id uint) (*models.Work, error) { +func (m *MockWorkRepository) GetByID(id uint) (*models2.Work, error) { for _, w := range m.works { if w.ID == id { return w, nil @@ -105,8 +104,8 @@ func (m *MockWorkRepository) GetByID(id uint) (*models.Work, error) { return nil, errors.New("not found") } -func (m *MockWorkRepository) FindByTitle(title string) ([]*models.Work, error) { - var result []*models.Work +func (m *MockWorkRepository) FindByTitle(title string) ([]*models2.Work, error) { + var result []*models2.Work for _, w := range m.works { if len(title) == 0 || (len(w.Title) >= len(title) && w.Title[:len(title)] == title) { result = append(result, w) @@ -115,8 +114,8 @@ func (m *MockWorkRepository) FindByTitle(title string) ([]*models.Work, error) { return result, nil } -func (m *MockWorkRepository) FindByLanguage(language string, page, pageSize int) (*repositories.PaginatedResult[*models.Work], error) { - var filtered []*models.Work +func (m *MockWorkRepository) FindByLanguage(language string, page, pageSize int) (*repositories2.PaginatedResult[*models2.Work], error) { + var filtered []*models2.Work for _, w := range m.works { if w.Language == language { filtered = append(filtered, w) @@ -126,12 +125,12 @@ func (m *MockWorkRepository) FindByLanguage(language string, page, pageSize int) start := (page - 1) * pageSize end := start + pageSize if start > len(filtered) { - return &repositories.PaginatedResult[*models.Work]{Items: []*models.Work{}, TotalCount: total}, nil + return &repositories2.PaginatedResult[*models2.Work]{Items: []*models2.Work{}, TotalCount: total}, nil } if end > len(filtered) { end = len(filtered) } - return &repositories.PaginatedResult[*models.Work]{Items: filtered[start:end], TotalCount: total}, nil + return &repositories2.PaginatedResult[*models2.Work]{Items: filtered[start:end], TotalCount: total}, nil } func (m *MockWorkRepository) Count() (int64, error) { @@ -145,7 +144,7 @@ type CachedWorkRepositorySuite struct { suite.Suite baseRepo *testutil.UnifiedMockWorkRepository cache *testCache - repo *repositories.CachedWorkRepository + repo *repositories2.CachedWorkRepository } func (s *CachedWorkRepositorySuite) SetupSuite() { @@ -156,7 +155,7 @@ func (s *CachedWorkRepositorySuite) SetupTest() { s.baseRepo = testutil.NewUnifiedMockWorkRepository() s.cache = &testCache{data: make(map[string][]byte)} - s.repo = repositories.NewCachedWorkRepository( + s.repo = repositories2.NewCachedWorkRepository( s.baseRepo, s.cache, nil, @@ -165,9 +164,9 @@ func (s *CachedWorkRepositorySuite) SetupTest() { } // createTestWork creates a test work and adds it to the mock repo -func (s *CachedWorkRepositorySuite) createTestWork(title, language string) *models.Work { - work := &models.Work{ - TranslatableModel: models.TranslatableModel{BaseModel: models.BaseModel{ID: 0}, Language: language}, +func (s *CachedWorkRepositorySuite) createTestWork(title, language string) *models2.Work { + work := &models2.Work{ + TranslatableModel: models2.TranslatableModel{BaseModel: models2.BaseModel{ID: 0}, Language: language}, Title: title, Description: "Test description", Status: "published", @@ -180,13 +179,13 @@ func (s *CachedWorkRepositorySuite) createTestWork(title, language string) *mode func (s *CachedWorkRepositorySuite) TestGetByID() { work := s.createTestWork("Test Work", "en") - result1, err := s.repo.GetByID(context.Background(), work.ID) + result1, err := s.repo.GetByID(context.Background(), work.ID) s.Require().NoError(err) s.Require().NotNil(result1) s.Equal(work.ID, result1.ID) s.Equal(work.Title, result1.Title) - result2, err := s.repo.GetByID(context.Background(), work.ID) + result2, err := s.repo.GetByID(context.Background(), work.ID) s.Require().NoError(err) s.Require().NotNil(result2) s.Equal(work.ID, result2.ID) @@ -202,11 +201,11 @@ func (s *CachedWorkRepositorySuite) TestFindByTitle() { work2 := s.createTestWork("Test Work 2", "en") _ = s.createTestWork("Another Work", "en") - works1, err := s.repo.FindByTitle(context.Background(), "Test") + works1, err := s.repo.FindByTitle(context.Background(), "Test") s.Require().NoError(err) s.Require().Len(works1, 2) - works2, err := s.repo.FindByTitle(context.Background(), "Test") + works2, err := s.repo.FindByTitle(context.Background(), "Test") s.Require().NoError(err) s.Require().Len(works2, 2) @@ -232,13 +231,13 @@ func (s *CachedWorkRepositorySuite) TestFindByLanguage() { s.createTestWork("Work 4", "fr") s.createTestWork("Work 5", "es") - result1, err := s.repo.FindByLanguage(context.Background(), "en", 1, 10) + result1, err := s.repo.FindByLanguage(context.Background(), "en", 1, 10) s.Require().NoError(err) s.Require().NotNil(result1) s.Equal(int64(2), result1.TotalCount) s.Equal(2, len(result1.Items)) - result2, err := s.repo.FindByLanguage(context.Background(), "en", 1, 10) + result2, err := s.repo.FindByLanguage(context.Background(), "en", 1, 10) s.Require().NoError(err) s.Require().NotNil(result2) s.Equal(int64(2), result2.TotalCount) diff --git a/repositories/category_repository.go b/internal/repositories/category_repository.go similarity index 98% rename from repositories/category_repository.go rename to internal/repositories/category_repository.go index 11a3994..fe69a21 100644 --- a/repositories/category_repository.go +++ b/internal/repositories/category_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // CategoryRepository defines CRUD methods specific to Category. diff --git a/repositories/city_repository.go b/internal/repositories/city_repository.go similarity index 97% rename from repositories/city_repository.go rename to internal/repositories/city_repository.go index 81bdf31..e2a74d2 100644 --- a/repositories/city_repository.go +++ b/internal/repositories/city_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // CityRepository defines CRUD methods specific to City. diff --git a/repositories/collection_repository.go b/internal/repositories/collection_repository.go similarity index 98% rename from repositories/collection_repository.go rename to internal/repositories/collection_repository.go index c359ec0..5ee0d3e 100644 --- a/repositories/collection_repository.go +++ b/internal/repositories/collection_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // CollectionRepository defines CRUD methods specific to Collection. diff --git a/repositories/comment_repository.go b/internal/repositories/comment_repository.go similarity index 98% rename from repositories/comment_repository.go rename to internal/repositories/comment_repository.go index 6f1d33e..b1bf1bc 100644 --- a/repositories/comment_repository.go +++ b/internal/repositories/comment_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // CommentRepository defines CRUD methods specific to Comment. diff --git a/repositories/contribution_repository.go b/internal/repositories/contribution_repository.go similarity index 99% rename from repositories/contribution_repository.go rename to internal/repositories/contribution_repository.go index a332134..c4bc86e 100644 --- a/repositories/contribution_repository.go +++ b/internal/repositories/contribution_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // ContributionRepository defines CRUD methods specific to Contribution. diff --git a/repositories/copyright_claim_repository.go b/internal/repositories/copyright_claim_repository.go similarity index 98% rename from repositories/copyright_claim_repository.go rename to internal/repositories/copyright_claim_repository.go index 2f04fbd..fbf8646 100644 --- a/repositories/copyright_claim_repository.go +++ b/internal/repositories/copyright_claim_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // CopyrightClaimRepository defines CRUD methods specific to CopyrightClaim. diff --git a/repositories/copyright_repository.go b/internal/repositories/copyright_repository.go similarity index 99% rename from repositories/copyright_repository.go rename to internal/repositories/copyright_repository.go index e70676b..e926e07 100644 --- a/repositories/copyright_repository.go +++ b/internal/repositories/copyright_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // CopyrightRepository defines CRUD methods specific to Copyright. diff --git a/repositories/country_repository.go b/internal/repositories/country_repository.go similarity index 98% rename from repositories/country_repository.go rename to internal/repositories/country_repository.go index 3dc7020..1fda91c 100644 --- a/repositories/country_repository.go +++ b/internal/repositories/country_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // CountryRepository defines CRUD methods specific to Country. diff --git a/repositories/edge_repository.go b/internal/repositories/edge_repository.go similarity index 97% rename from repositories/edge_repository.go rename to internal/repositories/edge_repository.go index b284696..e687b14 100644 --- a/repositories/edge_repository.go +++ b/internal/repositories/edge_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // EdgeRepository defines CRUD operations for the polymorphic edge table. diff --git a/repositories/edition_repository.go b/internal/repositories/edition_repository.go similarity index 98% rename from repositories/edition_repository.go rename to internal/repositories/edition_repository.go index a86f4e0..288d14b 100644 --- a/repositories/edition_repository.go +++ b/internal/repositories/edition_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // EditionRepository defines CRUD methods specific to Edition. diff --git a/repositories/email_verification_repository.go b/internal/repositories/email_verification_repository.go similarity index 98% rename from repositories/email_verification_repository.go rename to internal/repositories/email_verification_repository.go index 81c44b0..605d856 100644 --- a/repositories/email_verification_repository.go +++ b/internal/repositories/email_verification_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" "time" ) diff --git a/repositories/like_repository.go b/internal/repositories/like_repository.go similarity index 98% rename from repositories/like_repository.go rename to internal/repositories/like_repository.go index 413e5e1..ce52511 100644 --- a/repositories/like_repository.go +++ b/internal/repositories/like_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // LikeRepository defines CRUD methods specific to Like. diff --git a/repositories/monetization_repository.go b/internal/repositories/monetization_repository.go similarity index 98% rename from repositories/monetization_repository.go rename to internal/repositories/monetization_repository.go index 70fe1d2..714da32 100644 --- a/repositories/monetization_repository.go +++ b/internal/repositories/monetization_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // MonetizationRepository defines CRUD methods specific to Monetization. diff --git a/repositories/password_reset_repository.go b/internal/repositories/password_reset_repository.go similarity index 98% rename from repositories/password_reset_repository.go rename to internal/repositories/password_reset_repository.go index 9347f93..bd7645d 100644 --- a/repositories/password_reset_repository.go +++ b/internal/repositories/password_reset_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" "time" ) diff --git a/repositories/place_repository.go b/internal/repositories/place_repository.go similarity index 99% rename from repositories/place_repository.go rename to internal/repositories/place_repository.go index c3089ab..95d10f5 100644 --- a/repositories/place_repository.go +++ b/internal/repositories/place_repository.go @@ -4,7 +4,7 @@ import ( "context" "gorm.io/gorm" "math" - "tercul/models" + "tercul/internal/models" ) // PlaceRepository defines CRUD methods specific to Place. diff --git a/repositories/publisher_repository.go b/internal/repositories/publisher_repository.go similarity index 97% rename from repositories/publisher_repository.go rename to internal/repositories/publisher_repository.go index 84bc2f8..adec5a1 100644 --- a/repositories/publisher_repository.go +++ b/internal/repositories/publisher_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // PublisherRepository defines CRUD methods specific to Publisher. diff --git a/repositories/source_repository.go b/internal/repositories/source_repository.go similarity index 98% rename from repositories/source_repository.go rename to internal/repositories/source_repository.go index b800e62..98efba7 100644 --- a/repositories/source_repository.go +++ b/internal/repositories/source_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // SourceRepository defines CRUD methods specific to Source. diff --git a/repositories/tag_repository.go b/internal/repositories/tag_repository.go similarity index 98% rename from repositories/tag_repository.go rename to internal/repositories/tag_repository.go index 349d159..b661f7f 100644 --- a/repositories/tag_repository.go +++ b/internal/repositories/tag_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // TagRepository defines CRUD methods specific to Tag. diff --git a/repositories/translation_repository.go b/internal/repositories/translation_repository.go similarity index 67% rename from repositories/translation_repository.go rename to internal/repositories/translation_repository.go index 086da07..594965e 100644 --- a/repositories/translation_repository.go +++ b/internal/repositories/translation_repository.go @@ -3,34 +3,34 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + models2 "tercul/internal/models" ) // TranslationRepository defines CRUD methods specific to Translation. type TranslationRepository interface { - BaseRepository[models.Translation] - ListByWorkID(ctx context.Context, workID uint) ([]models.Translation, error) - ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models.Translation, error) - ListByTranslatorID(ctx context.Context, translatorID uint) ([]models.Translation, error) - ListByStatus(ctx context.Context, status models.TranslationStatus) ([]models.Translation, error) + BaseRepository[models2.Translation] + ListByWorkID(ctx context.Context, workID uint) ([]models2.Translation, error) + ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models2.Translation, error) + ListByTranslatorID(ctx context.Context, translatorID uint) ([]models2.Translation, error) + ListByStatus(ctx context.Context, status models2.TranslationStatus) ([]models2.Translation, error) } type translationRepository struct { - BaseRepository[models.Translation] + BaseRepository[models2.Translation] db *gorm.DB } // NewTranslationRepository creates a new TranslationRepository. func NewTranslationRepository(db *gorm.DB) TranslationRepository { return &translationRepository{ - BaseRepository: NewBaseRepositoryImpl[models.Translation](db), + BaseRepository: NewBaseRepositoryImpl[models2.Translation](db), db: db, } } // ListByWorkID finds translations by work ID -func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Translation, error) { - var translations []models.Translation +func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ([]models2.Translation, error) { + var translations []models2.Translation if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", workID, "Work").Find(&translations).Error; err != nil { return nil, err } @@ -38,8 +38,8 @@ func (r *translationRepository) ListByWorkID(ctx context.Context, workID uint) ( } // ListByEntity finds translations by entity type and ID -func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models.Translation, error) { - var translations []models.Translation +func (r *translationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models2.Translation, error) { + var translations []models2.Translation if err := r.db.WithContext(ctx).Where("translatable_id = ? AND translatable_type = ?", entityID, entityType).Find(&translations).Error; err != nil { return nil, err } @@ -47,8 +47,8 @@ func (r *translationRepository) ListByEntity(ctx context.Context, entityType str } // ListByTranslatorID finds translations by translator ID -func (r *translationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]models.Translation, error) { - var translations []models.Translation +func (r *translationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]models2.Translation, error) { + var translations []models2.Translation if err := r.db.WithContext(ctx).Where("translator_id = ?", translatorID).Find(&translations).Error; err != nil { return nil, err } @@ -56,8 +56,8 @@ func (r *translationRepository) ListByTranslatorID(ctx context.Context, translat } // ListByStatus finds translations by status -func (r *translationRepository) ListByStatus(ctx context.Context, status models.TranslationStatus) ([]models.Translation, error) { - var translations []models.Translation +func (r *translationRepository) ListByStatus(ctx context.Context, status models2.TranslationStatus) ([]models2.Translation, error) { + var translations []models2.Translation if err := r.db.WithContext(ctx).Where("status = ?", status).Find(&translations).Error; err != nil { return nil, err } diff --git a/repositories/user_profile_repository.go b/internal/repositories/user_profile_repository.go similarity index 97% rename from repositories/user_profile_repository.go rename to internal/repositories/user_profile_repository.go index 93cf051..a3d922d 100644 --- a/repositories/user_profile_repository.go +++ b/internal/repositories/user_profile_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // UserProfileRepository defines CRUD methods specific to UserProfile. diff --git a/repositories/user_repository.go b/internal/repositories/user_repository.go similarity index 68% rename from repositories/user_repository.go rename to internal/repositories/user_repository.go index 39d5810..71f143a 100644 --- a/repositories/user_repository.go +++ b/internal/repositories/user_repository.go @@ -4,33 +4,33 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + models2 "tercul/internal/models" ) // UserRepository defines CRUD methods specific to User. type UserRepository interface { - BaseRepository[models.User] - FindByUsername(ctx context.Context, username string) (*models.User, error) - FindByEmail(ctx context.Context, email string) (*models.User, error) - ListByRole(ctx context.Context, role models.UserRole) ([]models.User, error) + BaseRepository[models2.User] + FindByUsername(ctx context.Context, username string) (*models2.User, error) + FindByEmail(ctx context.Context, email string) (*models2.User, error) + ListByRole(ctx context.Context, role models2.UserRole) ([]models2.User, error) } type userRepository struct { - BaseRepository[models.User] + BaseRepository[models2.User] db *gorm.DB } // NewUserRepository creates a new UserRepository. func NewUserRepository(db *gorm.DB) UserRepository { return &userRepository{ - BaseRepository: NewBaseRepositoryImpl[models.User](db), + BaseRepository: NewBaseRepositoryImpl[models2.User](db), db: db, } } // FindByUsername finds a user by username -func (r *userRepository) FindByUsername(ctx context.Context, username string) (*models.User, error) { - var user models.User +func (r *userRepository) FindByUsername(ctx context.Context, username string) (*models2.User, error) { + var user models2.User if err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrEntityNotFound @@ -41,8 +41,8 @@ func (r *userRepository) FindByUsername(ctx context.Context, username string) (* } // FindByEmail finds a user by email -func (r *userRepository) FindByEmail(ctx context.Context, email string) (*models.User, error) { - var user models.User +func (r *userRepository) FindByEmail(ctx context.Context, email string) (*models2.User, error) { + var user models2.User if err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrEntityNotFound @@ -53,8 +53,8 @@ func (r *userRepository) FindByEmail(ctx context.Context, email string) (*models } // ListByRole lists users by role -func (r *userRepository) ListByRole(ctx context.Context, role models.UserRole) ([]models.User, error) { - var users []models.User +func (r *userRepository) ListByRole(ctx context.Context, role models2.UserRole) ([]models2.User, error) { + var users []models2.User if err := r.db.WithContext(ctx).Where("role = ?", role).Find(&users).Error; err != nil { return nil, err } diff --git a/repositories/user_session_repository.go b/internal/repositories/user_session_repository.go similarity index 98% rename from repositories/user_session_repository.go rename to internal/repositories/user_session_repository.go index a225d1b..26701df 100644 --- a/repositories/user_session_repository.go +++ b/internal/repositories/user_session_repository.go @@ -4,7 +4,7 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" "time" ) diff --git a/repositories/work_repository.go b/internal/repositories/work_repository.go similarity index 99% rename from repositories/work_repository.go rename to internal/repositories/work_repository.go index a307863..97fdbfb 100644 --- a/repositories/work_repository.go +++ b/internal/repositories/work_repository.go @@ -3,7 +3,7 @@ package repositories import ( "context" "gorm.io/gorm" - "tercul/models" + "tercul/internal/models" ) // WorkRepository defines methods specific to Work. diff --git a/repositories/work_repository_test.go b/internal/repositories/work_repository_test.go similarity index 51% rename from repositories/work_repository_test.go rename to internal/repositories/work_repository_test.go index 777a205..b6fbb4f 100644 --- a/repositories/work_repository_test.go +++ b/internal/repositories/work_repository_test.go @@ -1,12 +1,12 @@ package repositories_test import ( - "context" - "tercul/internal/testutil" - "tercul/models" - "testing" + "context" + models2 "tercul/internal/models" + "tercul/internal/testutil" + "testing" - "github.com/stretchr/testify/suite" + "github.com/stretchr/testify/suite" ) // WorkRepositorySuite is a test suite for the MockWorkRepository @@ -20,31 +20,31 @@ func (s *WorkRepositorySuite) SetupTest() { } func (s *WorkRepositorySuite) TestCreate() { - work := &models.Work{Title: "Test Work"} - err := s.repo.Create(context.Background(), work) + work := &models2.Work{Title: "Test Work"} + err := s.repo.Create(context.Background(), work) s.Require().NoError(err) } func (s *WorkRepositorySuite) TestGetByID() { - work := &models.Work{Title: "Test Work"} - s.repo.Create(context.Background(), work) - got, err := s.repo.GetByID(context.Background(), work.ID) + work := &models2.Work{Title: "Test Work"} + s.repo.Create(context.Background(), work) + got, err := s.repo.GetByID(context.Background(), work.ID) s.Require().NoError(err) s.Require().Equal(work.ID, got.ID) } func (s *WorkRepositorySuite) TestFindByTitle() { - work := &models.Work{Title: "Test"} - s.repo.Create(context.Background(), work) - works, err := s.repo.FindByTitle(context.Background(), "Test") + work := &models2.Work{Title: "Test"} + s.repo.Create(context.Background(), work) + works, err := s.repo.FindByTitle(context.Background(), "Test") s.Require().NoError(err) s.Require().Len(works, 1) } func (s *WorkRepositorySuite) TestFindByLanguage() { - work := &models.Work{TranslatableModel: models.TranslatableModel{Language: "en"}, Title: "Test"} - s.repo.Create(context.Background(), work) - result, err := s.repo.FindByLanguage(context.Background(), "en", 1, 10) + work := &models2.Work{TranslatableModel: models2.TranslatableModel{Language: "en"}, Title: "Test"} + s.repo.Create(context.Background(), work) + result, err := s.repo.FindByLanguage(context.Background(), "en", 1, 10) s.Require().NoError(err) s.Require().Len(result.Items, 1) } diff --git a/internal/store/db.go b/internal/store/db.go index cb737cd..3f0853b 100644 --- a/internal/store/db.go +++ b/internal/store/db.go @@ -3,7 +3,7 @@ package store import ( "gorm.io/gorm" "strings" - "tercul/models" + models2 "tercul/internal/models" ) // DB represents a database connection @@ -24,14 +24,14 @@ func ListPendingWorks(db *DB) []Work { var works []Work // Query for works that haven't been enriched yet - var modelWorks []models.Work + var modelWorks []models2.Work db.Where("id NOT IN (SELECT work_id FROM language_analyses)").Find(&modelWorks) // Convert to store.Work for _, work := range modelWorks { // Prefer original language translation; fallback to work language; then any var content string - var t models.Translation + var t models2.Translation // Try original if err := db.Where("translatable_type = ? AND translatable_id = ? AND is_original_language = ?", "Work", work.ID, true). First(&t).Error; err == nil { @@ -61,7 +61,7 @@ func ListPendingWorks(db *DB) []Work { // UpsertWord creates or updates a word in the database func UpsertWord(db *DB, workID uint, text, lemma, pos, phonetic string) error { // Check if the word already exists - var word models.Word + var word models2.Word result := db.Where("text = ? AND language = ?", text, "auto").First(&word) if result.Error != nil && result.Error != gorm.ErrRecordNotFound { @@ -71,7 +71,7 @@ func UpsertWord(db *DB, workID uint, text, lemma, pos, phonetic string) error { // Create or update the word if result.Error == gorm.ErrRecordNotFound { // Create new word - word = models.Word{ + word = models2.Word{ Text: text, Language: "auto", // This would be set to the detected language PartOfSpeech: pos, @@ -101,7 +101,7 @@ func SaveKeywords(db *DB, workID uint, keywords []string) error { } // Create a topic cluster for the keywords - cluster := models.TopicCluster{ + cluster := models2.TopicCluster{ Name: "Auto-generated", Description: "Automatically generated keywords", Keywords: strings.Join(keywords, ", "), @@ -117,7 +117,7 @@ func SaveKeywords(db *DB, workID uint, keywords []string) error { // SavePoetics saves poetic analysis for a work func SavePoetics(db *DB, workID uint, metrics PoeticMetrics) error { - poetics := models.PoeticAnalysis{ + poetics := models2.PoeticAnalysis{ WorkID: workID, Language: "auto", // This would be set to the detected language RhymeScheme: metrics.RhymeScheme, @@ -133,10 +133,10 @@ func SavePoetics(db *DB, workID uint, metrics PoeticMetrics) error { // MarkEnriched marks a work as enriched with the detected language func MarkEnriched(db *DB, workID uint, language string) error { // Create a language analysis record to mark the work as processed - analysis := models.LanguageAnalysis{ + analysis := models2.LanguageAnalysis{ WorkID: workID, Language: language, - Analysis: models.JSONB{ + Analysis: models2.JSONB{ "enriched": true, "language": language, }, diff --git a/internal/testutil/integration_test_utils.go b/internal/testutil/integration_test_utils.go new file mode 100644 index 0000000..d5d59a0 --- /dev/null +++ b/internal/testutil/integration_test_utils.go @@ -0,0 +1,348 @@ +package testutil + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/suite" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + "tercul/internal/models" + "tercul/internal/repositories" + "tercul/services" + "tercul/graph" +) + +// IntegrationTestSuite provides a comprehensive test environment with either in-memory SQLite or mock repositories +type IntegrationTestSuite struct { + suite.Suite + DB *gorm.DB + WorkRepo repositories.WorkRepository + UserRepo repositories.UserRepository + AuthorRepo repositories.AuthorRepository + TranslationRepo repositories.TranslationRepository + CommentRepo repositories.CommentRepository + LikeRepo repositories.LikeRepository + BookmarkRepo repositories.BookmarkRepository + CollectionRepo repositories.CollectionRepository + TagRepo repositories.TagRepository + CategoryRepo repositories.CategoryRepository + + // Services + WorkService services.WorkService + Localization services.LocalizationService + AuthService services.AuthService + + // Test data + TestWorks []*models.Work + TestUsers []*models.User + TestAuthors []*models.Author + TestTranslations []*models.Translation +} + +// TestConfig holds configuration for the test environment +type TestConfig struct { + UseInMemoryDB bool // If true, use SQLite in-memory, otherwise use mock repositories + DBPath string // Path for SQLite file (only used if UseInMemoryDB is false) + LogLevel logger.LogLevel +} + +// DefaultTestConfig returns a default test configuration +func DefaultTestConfig() *TestConfig { + return &TestConfig{ + UseInMemoryDB: true, + DBPath: "", + LogLevel: logger.Silent, + } +} + +// SetupSuite sets up the test suite with the specified configuration +func (s *IntegrationTestSuite) SetupSuite(config *TestConfig) { + if config == nil { + config = DefaultTestConfig() + } + + if config.UseInMemoryDB { + s.setupInMemoryDB(config) + } else { + s.setupMockRepositories() + } + + s.setupServices() + s.setupTestData() +} + +// setupInMemoryDB sets up an in-memory SQLite database for testing +func (s *IntegrationTestSuite) setupInMemoryDB(config *TestConfig) { + var dbPath string + if config.DBPath != "" { + // Ensure directory exists + dir := filepath.Dir(config.DBPath) + if err := os.MkdirAll(dir, 0755); err != nil { + s.T().Fatalf("Failed to create database directory: %v", err) + } + dbPath = config.DBPath + } else { + // Use in-memory database + dbPath = ":memory:" + } + + // Custom logger for tests + newLogger := logger.New( + log.New(os.Stdout, "\r\n", log.LstdFlags), + logger.Config{ + SlowThreshold: time.Second, + LogLevel: config.LogLevel, + IgnoreRecordNotFoundError: true, + Colorful: false, + }, + ) + + db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{ + Logger: newLogger, + }) + if err != nil { + s.T().Fatalf("Failed to connect to test database: %v", err) + } + + s.DB = db + + // Run migrations + if err := db.AutoMigrate( + &models.Work{}, + &models.User{}, + &models.Author{}, + &models.Translation{}, + &models.Comment{}, + &models.Like{}, + &models.Bookmark{}, + &models.Collection{}, + &models.Tag{}, + &models.Category{}, + &models.Country{}, + &models.City{}, + &models.Place{}, + &models.Address{}, + &models.Copyright{}, + &models.CopyrightClaim{}, + &models.Monetization{}, + &models.Book{}, + &models.Publisher{}, + &models.Source{}, + // &models.WorkAnalytics{}, // Commented out as it's not in models package + &models.ReadabilityScore{}, + &models.WritingStyle{}, + &models.Emotion{}, + &models.TopicCluster{}, + &models.Mood{}, + &models.Concept{}, + &models.LinguisticLayer{}, + &models.WorkStats{}, + &models.TextMetadata{}, + &models.PoeticAnalysis{}, + &models.TranslationField{}, + ); err != nil { + s.T().Fatalf("Failed to run migrations: %v", err) + } + + // Create repository instances + s.WorkRepo = repositories.NewWorkRepository(db) + s.UserRepo = repositories.NewUserRepository(db) + s.AuthorRepo = repositories.NewAuthorRepository(db) + s.TranslationRepo = repositories.NewTranslationRepository(db) + s.CommentRepo = repositories.NewCommentRepository(db) + s.LikeRepo = repositories.NewLikeRepository(db) + s.BookmarkRepo = repositories.NewBookmarkRepository(db) + s.CollectionRepo = repositories.NewCollectionRepository(db) + s.TagRepo = repositories.NewTagRepository(db) + s.CategoryRepo = repositories.NewCategoryRepository(db) +} + +// setupMockRepositories sets up mock repositories for testing +func (s *IntegrationTestSuite) setupMockRepositories() { + s.WorkRepo = NewUnifiedMockWorkRepository() + // Temporarily comment out problematic repositories until we fix the interface implementations + // s.UserRepo = NewMockUserRepository() + // s.AuthorRepo = NewMockAuthorRepository() + // s.TranslationRepo = NewMockTranslationRepository() + // s.CommentRepo = NewMockCommentRepository() + // s.LikeRepo = NewMockLikeRepository() + // s.BookmarkRepo = NewMockBookmarkRepository() + // s.CollectionRepo = NewMockCollectionRepository() + // s.TagRepo = NewMockTagRepository() + // s.CategoryRepo = NewMockCategoryRepository() +} + +// setupServices sets up service instances +func (s *IntegrationTestSuite) setupServices() { + s.WorkService = services.NewWorkService(s.WorkRepo, nil) + // Temporarily comment out services that depend on problematic repositories + // s.Localization = services.NewLocalizationService(s.TranslationRepo) + // s.AuthService = services.NewAuthService(s.UserRepo, "test-secret-key") +} + +// setupTestData creates initial test data +func (s *IntegrationTestSuite) setupTestData() { + // Create test users + s.TestUsers = []*models.User{ + {Username: "testuser1", Email: "test1@example.com", FirstName: "Test", LastName: "User1"}, + {Username: "testuser2", Email: "test2@example.com", FirstName: "Test", LastName: "User2"}, + } + + for _, user := range s.TestUsers { + if err := s.UserRepo.Create(context.Background(), user); err != nil { + s.T().Logf("Warning: Failed to create test user: %v", err) + } + } + + // Create test authors + s.TestAuthors = []*models.Author{ + {Name: "Test Author 1", Language: "en"}, + {Name: "Test Author 2", Language: "fr"}, + } + + for _, author := range s.TestAuthors { + if err := s.AuthorRepo.Create(context.Background(), author); err != nil { + s.T().Logf("Warning: Failed to create test author: %v", err) + } + } + + // Create test works + s.TestWorks = []*models.Work{ + {Title: "Test Work 1", Language: "en"}, + {Title: "Test Work 2", Language: "en"}, + {Title: "Test Work 3", Language: "fr"}, + } + + for _, work := range s.TestWorks { + if err := s.WorkRepo.Create(context.Background(), work); err != nil { + s.T().Logf("Warning: Failed to create test work: %v", err) + } + } + + // Create test translations + s.TestTranslations = []*models.Translation{ + { + Title: "Test Work 1", + Content: "Test content for work 1", + Language: "en", + TranslatableID: s.TestWorks[0].ID, + TranslatableType: "Work", + IsOriginalLanguage: true, + }, + { + Title: "Test Work 2", + Content: "Test content for work 2", + Language: "en", + TranslatableID: s.TestWorks[1].ID, + TranslatableType: "Work", + IsOriginalLanguage: true, + }, + { + Title: "Test Work 3", + Content: "Test content for work 3", + Language: "fr", + TranslatableID: s.TestWorks[2].ID, + TranslatableType: "Work", + IsOriginalLanguage: true, + }, + } + + for _, translation := range s.TestTranslations { + if err := s.TranslationRepo.Create(context.Background(), translation); err != nil { + s.T().Logf("Warning: Failed to create test translation: %v", err) + } + } +} + +// TearDownSuite cleans up the test suite +func (s *IntegrationTestSuite) TearDownSuite() { + if s.DB != nil { + sqlDB, err := s.DB.DB() + if err == nil { + sqlDB.Close() + } + } +} + +// SetupTest resets test data for each test +func (s *IntegrationTestSuite) SetupTest() { + if s.DB != nil { + // Reset database for each test + s.DB.Exec("DELETE FROM translations") + s.DB.Exec("DELETE FROM works") + s.DB.Exec("DELETE FROM authors") + s.DB.Exec("DELETE FROM users") + s.setupTestData() + } else { + // Reset mock repositories + if mockRepo, ok := s.WorkRepo.(*UnifiedMockWorkRepository); ok { + mockRepo.Reset() + } + // Add similar reset logic for other mock repositories + } +} + +// GetResolver returns a properly configured GraphQL resolver for testing +func (s *IntegrationTestSuite) GetResolver() *graph.Resolver { + return &graph.Resolver{ + WorkRepo: s.WorkRepo, + UserRepo: s.UserRepo, + AuthorRepo: s.AuthorRepo, + TranslationRepo: s.TranslationRepo, + CommentRepo: s.CommentRepo, + LikeRepo: s.LikeRepo, + BookmarkRepo: s.BookmarkRepo, + CollectionRepo: s.CollectionRepo, + TagRepo: s.TagRepo, + CategoryRepo: s.CategoryRepo, + WorkService: s.WorkService, + Localization: s.Localization, + AuthService: s.AuthService, + } +} + +// CreateTestWork creates a test work with optional content +func (s *IntegrationTestSuite) CreateTestWork(title, language string, content string) *models.Work { + work := &models.Work{ + Title: title, + } + work.Language = language + + if err := s.WorkRepo.Create(context.Background(), work); err != nil { + s.T().Fatalf("Failed to create test work: %v", err) + } + + if content != "" { + translation := &models.Translation{ + Title: title, + Content: content, + Language: language, + TranslatableID: work.ID, + TranslatableType: "Work", + IsOriginalLanguage: true, + } + + if err := s.TranslationRepo.Create(context.Background(), translation); err != nil { + s.T().Logf("Warning: Failed to create test translation: %v", err) + } + } + + return work +} + +// CleanupTestData removes all test data +func (s *IntegrationTestSuite) CleanupTestData() { + if s.DB != nil { + s.DB.Exec("DELETE FROM translations") + s.DB.Exec("DELETE FROM works") + s.DB.Exec("DELETE FROM authors") + s.DB.Exec("DELETE FROM users") + } +} diff --git a/internal/testutil/mock_base_repository.go b/internal/testutil/mock_base_repository.go new file mode 100644 index 0000000..e8197f1 --- /dev/null +++ b/internal/testutil/mock_base_repository.go @@ -0,0 +1,72 @@ +package testutil + +import ( + "context" + "errors" + "fmt" + "tercul/internal/repositories" + "gorm.io/gorm" +) + +// MockBaseRepository provides common mock implementations for BaseRepository methods +type MockBaseRepository[T any] struct { + // This is a helper struct that can be embedded in mock repositories + // to provide common mock implementations +} + +// BeginTx starts a new transaction (mock implementation) +func (m *MockBaseRepository[T]) BeginTx(ctx context.Context) (*gorm.DB, error) { + return nil, fmt.Errorf("transactions not supported in mock repository") +} + +// WithTx executes a function within a transaction (mock implementation) +func (m *MockBaseRepository[T]) WithTx(ctx context.Context, fn func(tx *gorm.DB) error) error { + return fmt.Errorf("transactions not supported in mock repository") +} + +// CreateInTx creates an entity within a transaction (mock implementation) +func (m *MockBaseRepository[T]) CreateInTx(ctx context.Context, tx *gorm.DB, entity *T) error { + return fmt.Errorf("CreateInTx not implemented in mock repository") +} + +// UpdateInTx updates an entity within a transaction (mock implementation) +func (m *MockBaseRepository[T]) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *T) error { + return fmt.Errorf("UpdateInTx not implemented in mock repository") +} + +// DeleteInTx removes an entity by its ID within a transaction (mock implementation) +func (m *MockBaseRepository[T]) DeleteInTx(ctx context.Context, tx *gorm.DB, id uint) error { + return fmt.Errorf("DeleteInTx not implemented in mock repository") +} + +// GetByIDWithOptions retrieves an entity by its ID with query options (mock implementation) +func (m *MockBaseRepository[T]) GetByIDWithOptions(ctx context.Context, id uint, options *repositories.QueryOptions) (*T, error) { + return nil, fmt.Errorf("GetByIDWithOptions not implemented in mock repository") +} + +// ListWithOptions returns entities with query options (mock implementation) +func (m *MockBaseRepository[T]) ListWithOptions(ctx context.Context, options *repositories.QueryOptions) ([]T, error) { + return nil, fmt.Errorf("ListWithOptions not implemented in mock repository") +} + +// CountWithOptions returns the count with query options (mock implementation) +func (m *MockBaseRepository[T]) CountWithOptions(ctx context.Context, options *repositories.QueryOptions) (int64, error) { + return 0, fmt.Errorf("CountWithOptions not implemented in mock repository") +} + +// Exists checks if an entity exists by ID (mock implementation) +func (m *MockBaseRepository[T]) Exists(ctx context.Context, id uint) (bool, error) { + return false, fmt.Errorf("Exists not implemented in mock repository") +} + +// GetAllForSync returns entities in batches for synchronization (mock implementation) +func (m *MockBaseRepository[T]) GetAllForSync(ctx context.Context, batchSize, offset int) ([]T, error) { + return nil, fmt.Errorf("GetAllForSync not implemented in mock repository") +} + +// AddMockBaseRepositoryMethods adds all the missing BaseRepository methods to a mock repository +// This is a helper function to avoid duplicating code +func AddMockBaseRepositoryMethods[T any](repo interface{}) { + // This function would use reflection to add methods, but for now + // we'll implement them manually in each repository +} diff --git a/internal/testutil/mock_translation_repository.go b/internal/testutil/mock_translation_repository.go index 40c8191..06b55d1 100644 --- a/internal/testutil/mock_translation_repository.go +++ b/internal/testutil/mock_translation_repository.go @@ -4,23 +4,23 @@ import ( "context" "errors" "gorm.io/gorm" - "tercul/models" - "tercul/repositories" + models2 "tercul/internal/models" + repositories2 "tercul/internal/repositories" ) // MockTranslationRepository is an in-memory implementation of TranslationRepository type MockTranslationRepository struct { - items []models.Translation + items []models2.Translation } func NewMockTranslationRepository() *MockTranslationRepository { - return &MockTranslationRepository{items: []models.Translation{}} + return &MockTranslationRepository{items: []models2.Translation{}} } -var _ repositories.TranslationRepository = (*MockTranslationRepository)(nil) +var _ repositories2.TranslationRepository = (*MockTranslationRepository)(nil) // BaseRepository methods with context support -func (m *MockTranslationRepository) Create(ctx context.Context, t *models.Translation) error { +func (m *MockTranslationRepository) Create(ctx context.Context, t *models2.Translation) error { if t == nil { return errors.New("nil translation") } @@ -29,24 +29,24 @@ func (m *MockTranslationRepository) Create(ctx context.Context, t *models.Transl return nil } -func (m *MockTranslationRepository) GetByID(ctx context.Context, id uint) (*models.Translation, error) { +func (m *MockTranslationRepository) GetByID(ctx context.Context, id uint) (*models2.Translation, error) { for i := range m.items { if m.items[i].ID == id { cp := m.items[i] return &cp, nil } } - return nil, repositories.ErrEntityNotFound + return nil, repositories2.ErrEntityNotFound } -func (m *MockTranslationRepository) Update(ctx context.Context, t *models.Translation) error { +func (m *MockTranslationRepository) Update(ctx context.Context, t *models2.Translation) error { for i := range m.items { if m.items[i].ID == t.ID { m.items[i] = *t return nil } } - return repositories.ErrEntityNotFound + return repositories2.ErrEntityNotFound } func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error { @@ -56,57 +56,57 @@ func (m *MockTranslationRepository) Delete(ctx context.Context, id uint) error { return nil } } - return repositories.ErrEntityNotFound + return repositories2.ErrEntityNotFound } -func (m *MockTranslationRepository) List(ctx context.Context, page, pageSize int) (*repositories.PaginatedResult[models.Translation], error) { - all := append([]models.Translation(nil), m.items...) +func (m *MockTranslationRepository) List(ctx context.Context, page, pageSize int) (*repositories2.PaginatedResult[models2.Translation], error) { + all := append([]models2.Translation(nil), m.items...) total := int64(len(all)) start := (page - 1) * pageSize end := start + pageSize if start > len(all) { - return &repositories.PaginatedResult[models.Translation]{Items: []models.Translation{}, TotalCount: total}, nil + return &repositories2.PaginatedResult[models2.Translation]{Items: []models2.Translation{}, TotalCount: total}, nil } if end > len(all) { end = len(all) } - return &repositories.PaginatedResult[models.Translation]{Items: all[start:end], TotalCount: total}, nil + return &repositories2.PaginatedResult[models2.Translation]{Items: all[start:end], TotalCount: total}, nil } -func (m *MockTranslationRepository) ListAll(ctx context.Context) ([]models.Translation, error) { - return append([]models.Translation(nil), m.items...), nil +func (m *MockTranslationRepository) ListAll(ctx context.Context) ([]models2.Translation, error) { + return append([]models2.Translation(nil), m.items...), nil } func (m *MockTranslationRepository) Count(ctx context.Context) (int64, error) { return int64(len(m.items)), nil } -func (m *MockTranslationRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*models.Translation, error) { +func (m *MockTranslationRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*models2.Translation, error) { return m.GetByID(ctx, id) } -func (m *MockTranslationRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]models.Translation, error) { - all := append([]models.Translation(nil), m.items...) +func (m *MockTranslationRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]models2.Translation, error) { + all := append([]models2.Translation(nil), m.items...) end := offset + batchSize if end > len(all) { end = len(all) } if offset > len(all) { - return []models.Translation{}, nil + return []models2.Translation{}, nil } return all[offset:end], nil } // New BaseRepository methods -func (m *MockTranslationRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *models.Translation) error { +func (m *MockTranslationRepository) CreateInTx(ctx context.Context, tx *gorm.DB, entity *models2.Translation) error { return m.Create(ctx, entity) } -func (m *MockTranslationRepository) GetByIDWithOptions(ctx context.Context, id uint, options *repositories.QueryOptions) (*models.Translation, error) { +func (m *MockTranslationRepository) GetByIDWithOptions(ctx context.Context, id uint, options *repositories2.QueryOptions) (*models2.Translation, error) { return m.GetByID(ctx, id) } -func (m *MockTranslationRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *models.Translation) error { +func (m *MockTranslationRepository) UpdateInTx(ctx context.Context, tx *gorm.DB, entity *models2.Translation) error { return m.Update(ctx, entity) } @@ -114,7 +114,7 @@ func (m *MockTranslationRepository) DeleteInTx(ctx context.Context, tx *gorm.DB, return m.Delete(ctx, id) } -func (m *MockTranslationRepository) ListWithOptions(ctx context.Context, options *repositories.QueryOptions) ([]models.Translation, error) { +func (m *MockTranslationRepository) ListWithOptions(ctx context.Context, options *repositories2.QueryOptions) ([]models2.Translation, error) { result, err := m.List(ctx, 1, 1000) if err != nil { return nil, err @@ -122,7 +122,7 @@ func (m *MockTranslationRepository) ListWithOptions(ctx context.Context, options return result.Items, nil } -func (m *MockTranslationRepository) CountWithOptions(ctx context.Context, options *repositories.QueryOptions) (int64, error) { +func (m *MockTranslationRepository) CountWithOptions(ctx context.Context, options *repositories2.QueryOptions) (int64, error) { return m.Count(ctx) } @@ -140,12 +140,12 @@ func (m *MockTranslationRepository) WithTx(ctx context.Context, fn func(tx *gorm } // TranslationRepository specific methods -func (m *MockTranslationRepository) ListByWorkID(ctx context.Context, workID uint) ([]models.Translation, error) { +func (m *MockTranslationRepository) ListByWorkID(ctx context.Context, workID uint) ([]models2.Translation, error) { return m.ListByEntity(ctx, "Work", workID) } -func (m *MockTranslationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models.Translation, error) { - var out []models.Translation +func (m *MockTranslationRepository) ListByEntity(ctx context.Context, entityType string, entityID uint) ([]models2.Translation, error) { + var out []models2.Translation for i := range m.items { tr := m.items[i] if tr.TranslatableType == entityType && tr.TranslatableID == entityID { @@ -155,8 +155,8 @@ func (m *MockTranslationRepository) ListByEntity(ctx context.Context, entityType return out, nil } -func (m *MockTranslationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]models.Translation, error) { - var out []models.Translation +func (m *MockTranslationRepository) ListByTranslatorID(ctx context.Context, translatorID uint) ([]models2.Translation, error) { + var out []models2.Translation for i := range m.items { if m.items[i].TranslatorID != nil && *m.items[i].TranslatorID == translatorID { out = append(out, m.items[i]) @@ -165,8 +165,8 @@ func (m *MockTranslationRepository) ListByTranslatorID(ctx context.Context, tran return out, nil } -func (m *MockTranslationRepository) ListByStatus(ctx context.Context, status models.TranslationStatus) ([]models.Translation, error) { - var out []models.Translation +func (m *MockTranslationRepository) ListByStatus(ctx context.Context, status models2.TranslationStatus) ([]models2.Translation, error) { + var out []models2.Translation for i := range m.items { if m.items[i].Status == status { out = append(out, m.items[i]) @@ -177,12 +177,12 @@ func (m *MockTranslationRepository) ListByStatus(ctx context.Context, status mod // Test helper: add a translation for a Work func (m *MockTranslationRepository) AddTranslationForWork(workID uint, language string, content string, isOriginal bool) { - m.Create(context.Background(), &models.Translation{ + m.Create(context.Background(), &models2.Translation{ Title: "", Content: content, Description: "", Language: language, - Status: models.TranslationStatusPublished, + Status: models2.TranslationStatusPublished, TranslatableID: workID, TranslatableType: "Work", IsOriginalLanguage: isOriginal, diff --git a/internal/testutil/mock_work_repository.go b/internal/testutil/mock_work_repository.go index 3f19dee..70f1882 100644 --- a/internal/testutil/mock_work_repository.go +++ b/internal/testutil/mock_work_repository.go @@ -3,8 +3,8 @@ package testutil import ( "context" "gorm.io/gorm" - "tercul/models" - "tercul/repositories" + "tercul/internal/models" + "tercul/internal/repositories" ) // UnifiedMockWorkRepository is a shared mock for WorkRepository tests diff --git a/internal/testutil/simple_test_utils.go b/internal/testutil/simple_test_utils.go new file mode 100644 index 0000000..d2265bd --- /dev/null +++ b/internal/testutil/simple_test_utils.go @@ -0,0 +1,52 @@ +package testutil + +import ( + "tercul/graph" + "tercul/internal/models" + "tercul/services" + + "github.com/stretchr/testify/suite" +) + +// SimpleTestSuite provides a minimal test environment with just the essentials +type SimpleTestSuite struct { + suite.Suite + WorkRepo *UnifiedMockWorkRepository + WorkService services.WorkService +} + +// SetupSuite sets up the test suite +func (s *SimpleTestSuite) SetupSuite() { + s.WorkRepo = NewUnifiedMockWorkRepository() + s.WorkService = services.NewWorkService(s.WorkRepo, nil) +} + +// SetupTest resets test data for each test +func (s *SimpleTestSuite) SetupTest() { + s.WorkRepo.Reset() +} + +// GetResolver returns a minimal GraphQL resolver for testing +func (s *SimpleTestSuite) GetResolver() *graph.Resolver { + return &graph.Resolver{ + WorkRepo: s.WorkRepo, + WorkService: s.WorkService, + // Other fields will be nil, but that's okay for basic tests + } +} + +// CreateTestWork creates a test work with optional content +func (s *SimpleTestSuite) CreateTestWork(title, language string, content string) *models.Work { + work := &models.Work{ + Title: title, + } + work.Language = language + + // Add work to the mock repository + s.WorkRepo.AddWork(work) + + // 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 work +} diff --git a/internal/testutil/testutil.go b/internal/testutil/testutil.go index 9428119..29e5fcc 100644 --- a/internal/testutil/testutil.go +++ b/internal/testutil/testutil.go @@ -12,7 +12,7 @@ import ( "gorm.io/driver/postgres" "gorm.io/gorm" "gorm.io/gorm/logger" - "tercul/config" + "tercul/internal/platform/config" ) // TestDB holds the test database connection diff --git a/linguistics/adapter_govader.go b/linguistics/adapter_govader.go index 7be64bb..a1c4ca5 100644 --- a/linguistics/adapter_govader.go +++ b/linguistics/adapter_govader.go @@ -1,24 +1,22 @@ package linguistics import ( - "github.com/jonreiter/govader" + "github.com/jonreiter/govader" ) // GoVADERSentimentProvider implements SentimentProvider using VADER type GoVADERSentimentProvider struct { - analyzer *govader.SentimentIntensityAnalyzer + analyzer *govader.SentimentIntensityAnalyzer } // NewGoVADERSentimentProvider constructs a VADER-based sentiment provider func NewGoVADERSentimentProvider() (*GoVADERSentimentProvider, error) { - analyzer := govader.NewSentimentIntensityAnalyzer() - return &GoVADERSentimentProvider{analyzer: analyzer}, nil + analyzer := govader.NewSentimentIntensityAnalyzer() + return &GoVADERSentimentProvider{analyzer: analyzer}, nil } // Score returns the compound VADER polarity score in [-1, 1] func (p *GoVADERSentimentProvider) Score(text string, _ string) (float64, error) { - scores := p.analyzer.PolarityScores(text) - return scores.Compound, nil + scores := p.analyzer.PolarityScores(text) + return scores.Compound, nil } - - diff --git a/linguistics/adapter_govader_test.go b/linguistics/adapter_govader_test.go index bb9fedf..8b4ea62 100644 --- a/linguistics/adapter_govader_test.go +++ b/linguistics/adapter_govader_test.go @@ -1,19 +1,17 @@ package linguistics import ( - "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/require" + "testing" ) func TestGoVADERSentimentProvider_Score(t *testing.T) { - sp, err := NewGoVADERSentimentProvider() - require.NoError(t, err) - pos, err := sp.Score("I love this wonderful product!", "en") - require.NoError(t, err) - require.Greater(t, pos, 0.0) - neg, err := sp.Score("This is the worst thing ever. I hate it.", "en") - require.NoError(t, err) - require.Less(t, neg, 0.0) + sp, err := NewGoVADERSentimentProvider() + require.NoError(t, err) + pos, err := sp.Score("I love this wonderful product!", "en") + require.NoError(t, err) + require.Greater(t, pos, 0.0) + neg, err := sp.Score("This is the worst thing ever. I hate it.", "en") + require.NoError(t, err) + require.Less(t, neg, 0.0) } - - diff --git a/linguistics/adapter_lingua.go b/linguistics/adapter_lingua.go index 185a4ac..ad13492 100644 --- a/linguistics/adapter_lingua.go +++ b/linguistics/adapter_lingua.go @@ -1,36 +1,34 @@ package linguistics import ( - "strings" - lingua "github.com/pemistahl/lingua-go" + lingua "github.com/pemistahl/lingua-go" + "strings" ) // LinguaLanguageDetector implements LanguageDetector using lingua-go type LinguaLanguageDetector struct { - detector lingua.LanguageDetector + detector lingua.LanguageDetector } // NewLinguaLanguageDetector builds a detector for all supported languages func NewLinguaLanguageDetector() *LinguaLanguageDetector { - det := lingua.NewLanguageDetectorBuilder().FromAllLanguages().Build() - return &LinguaLanguageDetector{detector: det} + det := lingua.NewLanguageDetectorBuilder().FromAllLanguages().Build() + return &LinguaLanguageDetector{detector: det} } // DetectLanguage returns a lowercase ISO 639-1 code if possible func (l *LinguaLanguageDetector) DetectLanguage(text string) (string, bool) { - lang, ok := l.detector.DetectLanguageOf(text) - if !ok { - return "", false - } - // Prefer ISO 639-1 when available else fallback to ISO 639-3 - if s := lang.IsoCode639_1().String(); s != "" { - return s, true - } - if s := lang.IsoCode639_3().String(); s != "" { - return s, true - } - // fallback to language name - return strings.ToLower(lang.String()), true + lang, ok := l.detector.DetectLanguageOf(text) + if !ok { + return "", false + } + // Prefer ISO 639-1 when available else fallback to ISO 639-3 + if s := lang.IsoCode639_1().String(); s != "" { + return s, true + } + if s := lang.IsoCode639_3().String(); s != "" { + return s, true + } + // fallback to language name + return strings.ToLower(lang.String()), true } - - diff --git a/linguistics/adapter_lingua_test.go b/linguistics/adapter_lingua_test.go index 8e637d5..1efd2a9 100644 --- a/linguistics/adapter_lingua_test.go +++ b/linguistics/adapter_lingua_test.go @@ -1,15 +1,13 @@ package linguistics import ( - "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/require" + "testing" ) func TestLinguaLanguageDetector_DetectLanguage(t *testing.T) { - d := NewLinguaLanguageDetector() - code, ok := d.DetectLanguage("This is an English sentence.") - require.True(t, ok) - require.NotEmpty(t, code) + d := NewLinguaLanguageDetector() + code, ok := d.DetectLanguage("This is an English sentence.") + require.True(t, ok) + require.NotEmpty(t, code) } - - diff --git a/linguistics/adapter_tfidf.go b/linguistics/adapter_tfidf.go index c854324..755fb4e 100644 --- a/linguistics/adapter_tfidf.go +++ b/linguistics/adapter_tfidf.go @@ -1,8 +1,8 @@ package linguistics import ( - "sort" - "strings" + "sort" + "strings" ) // TFIDFKeywordProvider is a lightweight keyword provider using local term frequencies as a proxy. @@ -13,33 +13,31 @@ type TFIDFKeywordProvider struct{} func NewTFIDFKeywordProvider() *TFIDFKeywordProvider { return &TFIDFKeywordProvider{} } func (p *TFIDFKeywordProvider) Extract(text string, language string) ([]Keyword, error) { - tokens := tokenizeWords(text) - if len(tokens) == 0 { - return nil, nil - } - freq := make(map[string]int, len(tokens)) - for _, t := range tokens { - if len(t) <= 2 || isStopWord(t, language) { - continue - } - freq[strings.ToLower(t)]++ - } - total := 0 - for _, c := range freq { - total += c - } - keywords := make([]Keyword, 0, len(freq)) - for w, c := range freq { - rel := float64(c) / float64(len(tokens)) - if rel > 0 { - keywords = append(keywords, Keyword{Text: w, Relevance: rel}) - } - } - sort.Slice(keywords, func(i, j int) bool { return keywords[i].Relevance > keywords[j].Relevance }) - if len(keywords) > 10 { - keywords = keywords[:10] - } - return keywords, nil + tokens := tokenizeWords(text) + if len(tokens) == 0 { + return nil, nil + } + freq := make(map[string]int, len(tokens)) + for _, t := range tokens { + if len(t) <= 2 || isStopWord(t, language) { + continue + } + freq[strings.ToLower(t)]++ + } + total := 0 + for _, c := range freq { + total += c + } + keywords := make([]Keyword, 0, len(freq)) + for w, c := range freq { + rel := float64(c) / float64(len(tokens)) + if rel > 0 { + keywords = append(keywords, Keyword{Text: w, Relevance: rel}) + } + } + sort.Slice(keywords, func(i, j int) bool { return keywords[i].Relevance > keywords[j].Relevance }) + if len(keywords) > 10 { + keywords = keywords[:10] + } + return keywords, nil } - - diff --git a/linguistics/adapters.go b/linguistics/adapters.go index 8bb6b2d..8c7a937 100644 --- a/linguistics/adapters.go +++ b/linguistics/adapters.go @@ -1,8 +1,8 @@ package linguistics import ( - "errors" - "strings" + "errors" + "strings" ) // --- LanguageDetector Adapters --- @@ -11,7 +11,7 @@ import ( type NullLanguageDetector struct{} func (n NullLanguageDetector) DetectLanguage(text string) (string, bool) { - return "", false + return "", false } // --- SentimentProvider Adapters --- @@ -20,10 +20,8 @@ func (n NullLanguageDetector) DetectLanguage(text string) (string, bool) { type RuleBasedSentimentProvider struct{} func (r RuleBasedSentimentProvider) Score(text string, language string) (float64, error) { - if strings.TrimSpace(text) == "" { - return 0, errors.New("empty text") - } - return estimateSentimentOptimized(text, language), nil + if strings.TrimSpace(text) == "" { + return 0, errors.New("empty text") + } + return estimateSentimentOptimized(text, language), nil } - - diff --git a/linguistics/analysis_cache.go b/linguistics/analysis_cache.go index 399dd60..afbe9d8 100644 --- a/linguistics/analysis_cache.go +++ b/linguistics/analysis_cache.go @@ -3,12 +3,12 @@ package linguistics import ( "context" "fmt" - "sync" "github.com/hashicorp/golang-lru/v2" - "tercul/cache" - "tercul/logger" - "tercul/config" - "time" + "sync" + "tercul/internal/platform/cache" + "tercul/internal/platform/config" + "tercul/internal/platform/log" + "time" ) // AnalysisCache defines the interface for caching analysis results @@ -32,12 +32,12 @@ type MemoryAnalysisCache struct { // NewMemoryAnalysisCache creates a new MemoryAnalysisCache func NewMemoryAnalysisCache(enabled bool) *MemoryAnalysisCache { - // capacity from config - cap := config.Cfg.NLPMemoryCacheCap - if cap <= 0 { - cap = 1024 - } - l, _ := lru.New[string, *AnalysisResult](cap) + // capacity from config + cap := config.Cfg.NLPMemoryCacheCap + if cap <= 0 { + cap = 1024 + } + l, _ := lru.New[string, *AnalysisResult](cap) return &MemoryAnalysisCache{ cache: l, enabled: enabled, @@ -53,9 +53,9 @@ func (c *MemoryAnalysisCache) Get(ctx context.Context, key string) (*AnalysisRes c.mutex.RLock() defer c.mutex.RUnlock() - if result, exists := c.cache.Get(key); exists { - return result, nil - } + if result, exists := c.cache.Get(key); exists { + return result, nil + } return nil, fmt.Errorf("cache miss") } @@ -113,13 +113,13 @@ func (c *RedisAnalysisCache) Set(ctx context.Context, key string, result *Analys return nil } - // TTL from config - ttlSeconds := config.Cfg.NLPRedisCacheTTLSeconds - err := c.cache.Set(ctx, key, result, time.Duration(ttlSeconds)*time.Second) + // TTL from config + ttlSeconds := config.Cfg.NLPRedisCacheTTLSeconds + err := c.cache.Set(ctx, key, result, time.Duration(ttlSeconds)*time.Second) if err != nil { - logger.LogWarn("Failed to cache analysis result", - logger.F("key", key), - logger.F("error", err)) + log.LogWarn("Failed to cache analysis result", + log.F("key", key), + log.F("error", err)) return err } @@ -176,16 +176,16 @@ func (c *CompositeAnalysisCache) Set(ctx context.Context, key string, result *An // Set in memory cache if err := c.memoryCache.Set(ctx, key, result); err != nil { - logger.LogWarn("Failed to set memory cache", - logger.F("key", key), - logger.F("error", err)) + log.LogWarn("Failed to set memory cache", + log.F("key", key), + log.F("error", err)) } // Set in Redis cache if err := c.redisCache.Set(ctx, key, result); err != nil { - logger.LogWarn("Failed to set Redis cache", - logger.F("key", key), - logger.F("error", err)) + log.LogWarn("Failed to set Redis cache", + log.F("key", key), + log.F("error", err)) return err } diff --git a/linguistics/analysis_repository.go b/linguistics/analysis_repository.go index 1f913d3..9d040cf 100644 --- a/linguistics/analysis_repository.go +++ b/linguistics/analysis_repository.go @@ -3,9 +3,10 @@ package linguistics import ( "context" "fmt" + models2 "tercul/internal/models" + "gorm.io/gorm" - "tercul/logger" - "tercul/models" + "tercul/internal/platform/log" ) // AnalysisRepository defines the interface for database operations related to analysis @@ -17,14 +18,14 @@ type AnalysisRepository interface { GetWorkContent(ctx context.Context, workID uint, language string) (string, error) // StoreWorkAnalysis stores work-specific analysis results - StoreWorkAnalysis(ctx context.Context, workID uint, textMetadata *models.TextMetadata, - readabilityScore *models.ReadabilityScore, languageAnalysis *models.LanguageAnalysis) error + StoreWorkAnalysis(ctx context.Context, workID uint, textMetadata *models2.TextMetadata, + readabilityScore *models2.ReadabilityScore, languageAnalysis *models2.LanguageAnalysis) error - // GetWorkByID fetches a work by ID - GetWorkByID(ctx context.Context, workID uint) (*models.Work, error) + // GetWorkByID fetches a work by ID + GetWorkByID(ctx context.Context, workID uint) (*models2.Work, error) - // GetAnalysisData fetches persisted analysis data for a work - GetAnalysisData(ctx context.Context, workID uint) (*models.TextMetadata, *models.ReadabilityScore, *models.LanguageAnalysis, error) + // GetAnalysisData fetches persisted analysis data for a work + GetAnalysisData(ctx context.Context, workID uint) (*models2.TextMetadata, *models2.ReadabilityScore, *models2.LanguageAnalysis, error) } // GORMAnalysisRepository implements AnalysisRepository using GORM @@ -43,19 +44,19 @@ func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workI return fmt.Errorf("analysis result cannot be nil") } - // Determine language from the work record to avoid hardcoded defaults - var work models.Work - if err := r.db.WithContext(ctx).First(&work, workID).Error; err != nil { - logger.LogError("Failed to fetch work for language", - logger.F("workID", workID), - logger.F("error", err)) - return fmt.Errorf("failed to fetch work for language: %w", err) - } + // Determine language from the work record to avoid hardcoded defaults + var work models2.Work + if err := r.db.WithContext(ctx).First(&work, workID).Error; err != nil { + log.LogError("Failed to fetch work for language", + log.F("workID", workID), + log.F("error", err)) + return fmt.Errorf("failed to fetch work for language: %w", err) + } // Create text metadata - textMetadata := &models.TextMetadata{ + textMetadata := &models2.TextMetadata{ WorkID: workID, - Language: work.Language, + Language: work.Language, WordCount: result.WordCount, SentenceCount: result.SentenceCount, ParagraphCount: result.ParagraphCount, @@ -64,18 +65,18 @@ func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workI } // Create readability score - readabilityScore := &models.ReadabilityScore{ - WorkID: workID, - Language: work.Language, - Score: result.ReadabilityScore, - Method: result.ReadabilityMethod, + readabilityScore := &models2.ReadabilityScore{ + WorkID: workID, + Language: work.Language, + Score: result.ReadabilityScore, + Method: result.ReadabilityMethod, } // Create language analysis - languageAnalysis := &models.LanguageAnalysis{ + languageAnalysis := &models2.LanguageAnalysis{ WorkID: workID, - Language: work.Language, - Analysis: models.JSONB{ + Language: work.Language, + Analysis: models2.JSONB{ "sentiment": result.Sentiment, "keywords": extractKeywordsAsJSON(result.Keywords), "topics": extractTopicsAsJSON(result.Topics), @@ -88,11 +89,11 @@ func (r *GORMAnalysisRepository) StoreAnalysisResults(ctx context.Context, workI // GetWorkContent retrieves content for a work from translations func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint, language string) (string, error) { // First, get the work to determine its language - var work models.Work + var work models2.Work if err := r.db.First(&work, workID).Error; err != nil { - logger.LogError("Failed to fetch work for content retrieval", - logger.F("workID", workID), - logger.F("error", err)) + log.LogError("Failed to fetch work for content retrieval", + log.F("workID", workID), + log.F("error", err)) return "", fmt.Errorf("failed to fetch work: %w", err) } @@ -101,7 +102,7 @@ func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint // 2. Work's language translation // 3. Any available translation - var translation models.Translation + var translation models2.Translation // Try original language first if err := r.db.Where("translatable_type = ? AND translatable_id = ? AND is_original_language = ?", @@ -125,107 +126,107 @@ func (r *GORMAnalysisRepository) GetWorkContent(ctx context.Context, workID uint } // GetWorkByID fetches a work by ID -func (r *GORMAnalysisRepository) GetWorkByID(ctx context.Context, workID uint) (*models.Work, error) { - var work models.Work - if err := r.db.WithContext(ctx).First(&work, workID).Error; err != nil { - return nil, fmt.Errorf("failed to fetch work: %w", err) - } - return &work, nil +func (r *GORMAnalysisRepository) GetWorkByID(ctx context.Context, workID uint) (*models2.Work, error) { + var work models2.Work + if err := r.db.WithContext(ctx).First(&work, workID).Error; err != nil { + return nil, fmt.Errorf("failed to fetch work: %w", err) + } + return &work, nil } // GetAnalysisData fetches persisted analysis data for a work -func (r *GORMAnalysisRepository) GetAnalysisData(ctx context.Context, workID uint) (*models.TextMetadata, *models.ReadabilityScore, *models.LanguageAnalysis, error) { - var textMetadata models.TextMetadata - var readabilityScore models.ReadabilityScore - var languageAnalysis models.LanguageAnalysis +func (r *GORMAnalysisRepository) GetAnalysisData(ctx context.Context, workID uint) (*models2.TextMetadata, *models2.ReadabilityScore, *models2.LanguageAnalysis, error) { + var textMetadata models2.TextMetadata + var readabilityScore models2.ReadabilityScore + var languageAnalysis models2.LanguageAnalysis - if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&textMetadata).Error; err != nil { - logger.LogWarn("No text metadata found for work", - logger.F("workID", workID)) - } + if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&textMetadata).Error; err != nil { + log.LogWarn("No text metadata found for work", + log.F("workID", workID)) + } - if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&readabilityScore).Error; err != nil { - logger.LogWarn("No readability score found for work", - logger.F("workID", workID)) - } + if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&readabilityScore).Error; err != nil { + log.LogWarn("No readability score found for work", + log.F("workID", workID)) + } - if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&languageAnalysis).Error; err != nil { - logger.LogWarn("No language analysis found for work", - logger.F("workID", workID)) - } + if err := r.db.WithContext(ctx).Where("work_id = ?", workID).First(&languageAnalysis).Error; err != nil { + log.LogWarn("No language analysis found for work", + log.F("workID", workID)) + } - return &textMetadata, &readabilityScore, &languageAnalysis, nil + return &textMetadata, &readabilityScore, &languageAnalysis, nil } // StoreWorkAnalysis stores work-specific analysis results func (r *GORMAnalysisRepository) StoreWorkAnalysis(ctx context.Context, workID uint, - textMetadata *models.TextMetadata, readabilityScore *models.ReadabilityScore, - languageAnalysis *models.LanguageAnalysis) error { + textMetadata *models2.TextMetadata, readabilityScore *models2.ReadabilityScore, + languageAnalysis *models2.LanguageAnalysis) error { // Use a transaction to ensure all data is stored atomically return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // Store text metadata if textMetadata != nil { - if err := tx.Where("work_id = ?", workID).Delete(&models.TextMetadata{}).Error; err != nil { - logger.LogError("Failed to delete existing text metadata", - logger.F("workID", workID), - logger.F("error", err)) + if err := tx.Where("work_id = ?", workID).Delete(&models2.TextMetadata{}).Error; err != nil { + log.LogError("Failed to delete existing text metadata", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to delete existing text metadata: %w", err) } if err := tx.Create(textMetadata).Error; err != nil { - logger.LogError("Failed to store text metadata", - logger.F("workID", workID), - logger.F("error", err)) + log.LogError("Failed to store text metadata", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to store text metadata: %w", err) } } // Store readability score if readabilityScore != nil { - if err := tx.Where("work_id = ?", workID).Delete(&models.ReadabilityScore{}).Error; err != nil { - logger.LogError("Failed to delete existing readability score", - logger.F("workID", workID), - logger.F("error", err)) + if err := tx.Where("work_id = ?", workID).Delete(&models2.ReadabilityScore{}).Error; err != nil { + log.LogError("Failed to delete existing readability score", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to delete existing readability score: %w", err) } if err := tx.Create(readabilityScore).Error; err != nil { - logger.LogError("Failed to store readability score", - logger.F("workID", workID), - logger.F("error", err)) + log.LogError("Failed to store readability score", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to store readability score: %w", err) } } // Store language analysis if languageAnalysis != nil { - if err := tx.Where("work_id = ?", workID).Delete(&models.LanguageAnalysis{}).Error; err != nil { - logger.LogError("Failed to delete existing language analysis", - logger.F("workID", workID), - logger.F("error", err)) + if err := tx.Where("work_id = ?", workID).Delete(&models2.LanguageAnalysis{}).Error; err != nil { + log.LogError("Failed to delete existing language analysis", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to delete existing language analysis: %w", err) } if err := tx.Create(languageAnalysis).Error; err != nil { - logger.LogError("Failed to store language analysis", - logger.F("workID", workID), - logger.F("error", err)) + log.LogError("Failed to store language analysis", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to store language analysis: %w", err) } } - logger.LogInfo("Successfully stored analysis results", - logger.F("workID", workID)) + log.LogInfo("Successfully stored analysis results", + log.F("workID", workID)) return nil }) } // Helper functions for data conversion -func extractKeywordsAsJSON(keywords []Keyword) models.JSONB { +func extractKeywordsAsJSON(keywords []Keyword) models2.JSONB { if len(keywords) == 0 { - return models.JSONB{} + return models2.JSONB{} } keywordData := make([]map[string]interface{}, len(keywords)) @@ -236,12 +237,12 @@ func extractKeywordsAsJSON(keywords []Keyword) models.JSONB { } } - return models.JSONB{"keywords": keywordData} + return models2.JSONB{"keywords": keywordData} } -func extractTopicsAsJSON(topics []Topic) models.JSONB { +func extractTopicsAsJSON(topics []Topic) models2.JSONB { if len(topics) == 0 { - return models.JSONB{} + return models2.JSONB{} } topicData := make([]map[string]interface{}, len(topics)) @@ -252,5 +253,5 @@ func extractTopicsAsJSON(topics []Topic) models.JSONB { } } - return models.JSONB{"topics": topicData} + return models2.JSONB{"topics": topicData} } diff --git a/linguistics/analyzer.go b/linguistics/analyzer.go index 8d14094..155dd10 100644 --- a/linguistics/analyzer.go +++ b/linguistics/analyzer.go @@ -1,12 +1,13 @@ package linguistics import ( - "context" - "crypto/sha256" - "encoding/hex" - "sync" - "tercul/cache" - "tercul/logger" + "context" + "crypto/sha256" + "encoding/hex" + "sync" + + "tercul/internal/platform/cache" + "tercul/internal/platform/log" ) // Analyzer defines the interface for linguistic analysis services @@ -22,34 +23,34 @@ type Analyzer interface { // It delegates pure text analysis to TextAnalyzer and work analysis to WorkAnalysisService, // and only handles caching and orchestration concerns here to preserve SRP/DRY. type BasicAnalyzer struct { - textAnalyzer TextAnalyzer - workAnalysisService WorkAnalysisService - cache cache.Cache - resultCache map[string]*AnalysisResult - cacheMutex sync.RWMutex - concurrency int - cacheEnabled bool + textAnalyzer TextAnalyzer + workAnalysisService WorkAnalysisService + cache cache.Cache + resultCache map[string]*AnalysisResult + cacheMutex sync.RWMutex + concurrency int + cacheEnabled bool } // NewBasicAnalyzer creates a new BasicAnalyzer func NewBasicAnalyzer( - textAnalyzer TextAnalyzer, - workService WorkAnalysisService, - redis cache.Cache, - concurrency int, - cacheEnabled bool, + textAnalyzer TextAnalyzer, + workService WorkAnalysisService, + redis cache.Cache, + concurrency int, + cacheEnabled bool, ) *BasicAnalyzer { - if concurrency <= 0 { - concurrency = 4 - } - return &BasicAnalyzer{ - textAnalyzer: textAnalyzer, - workAnalysisService: workService, - cache: redis, - resultCache: make(map[string]*AnalysisResult), - concurrency: concurrency, - cacheEnabled: cacheEnabled, - } + if concurrency <= 0 { + concurrency = 4 + } + return &BasicAnalyzer{ + textAnalyzer: textAnalyzer, + workAnalysisService: workService, + cache: redis, + resultCache: make(map[string]*AnalysisResult), + concurrency: concurrency, + cacheEnabled: cacheEnabled, + } } // WithCache adds a cache to the analyzer @@ -78,86 +79,86 @@ func (a *BasicAnalyzer) DisableCache() { // AnalyzeText performs basic linguistic analysis on the given text func (a *BasicAnalyzer) AnalyzeText(ctx context.Context, text string, language string) (*AnalysisResult, error) { - // Check in-memory cache first if enabled - if a.cacheEnabled { - cacheKey := makeTextCacheKey(language, text) + // Check in-memory cache first if enabled + if a.cacheEnabled { + cacheKey := makeTextCacheKey(language, text) - // Try to get from in-memory cache - a.cacheMutex.RLock() - cachedResult, found := a.resultCache[cacheKey] - a.cacheMutex.RUnlock() + // Try to get from in-memory cache + a.cacheMutex.RLock() + cachedResult, found := a.resultCache[cacheKey] + a.cacheMutex.RUnlock() - if found { - logger.LogDebug("In-memory cache hit for text analysis", - logger.F("language", language), - logger.F("textLength", len(text))) - return cachedResult, nil - } + if found { + log.LogDebug("In-memory cache hit for text analysis", + log.F("language", language), + log.F("textLength", len(text))) + return cachedResult, nil + } - // Try to get from Redis cache if available - if a.cache != nil { - var cachedResult AnalysisResult - err := a.cache.Get(ctx, "text_analysis:"+cacheKey, &cachedResult) - if err == nil { - logger.LogDebug("Redis cache hit for text analysis", - logger.F("language", language), - logger.F("textLength", len(text))) + // Try to get from Redis cache if available + if a.cache != nil { + var cachedResult AnalysisResult + err := a.cache.Get(ctx, "text_analysis:"+cacheKey, &cachedResult) + if err == nil { + log.LogDebug("Redis cache hit for text analysis", + log.F("language", language), + log.F("textLength", len(text))) - // Store in in-memory cache too - a.cacheMutex.Lock() - a.resultCache[cacheKey] = &cachedResult - a.cacheMutex.Unlock() + // Store in in-memory cache too + a.cacheMutex.Lock() + a.resultCache[cacheKey] = &cachedResult + a.cacheMutex.Unlock() - return &cachedResult, nil - } - } - } + return &cachedResult, nil + } + } + } - // Cache miss or caching disabled, perform analysis using the pure TextAnalyzer - logger.LogDebug("Performing text analysis", - logger.F("language", language), - logger.F("textLength", len(text))) + // Cache miss or caching disabled, perform analysis using the pure TextAnalyzer + log.LogDebug("Performing text analysis", + log.F("language", language), + log.F("textLength", len(text))) - var ( - result *AnalysisResult - err error - ) - if len(text) > 10000 && a.concurrency > 1 { - result, err = a.textAnalyzer.AnalyzeTextConcurrently(ctx, text, language, a.concurrency) - } else { - result, err = a.textAnalyzer.AnalyzeText(ctx, text, language) - } - if err != nil { - return nil, err - } + var ( + result *AnalysisResult + err error + ) + if len(text) > 10000 && a.concurrency > 1 { + result, err = a.textAnalyzer.AnalyzeTextConcurrently(ctx, text, language, a.concurrency) + } else { + result, err = a.textAnalyzer.AnalyzeText(ctx, text, language) + } + if err != nil { + return nil, err + } - // Cache the result if caching is enabled - if a.cacheEnabled { - cacheKey := makeTextCacheKey(language, text) + // Cache the result if caching is enabled + if a.cacheEnabled { + cacheKey := makeTextCacheKey(language, text) - // Store in in-memory cache - a.cacheMutex.Lock() - a.resultCache[cacheKey] = result - a.cacheMutex.Unlock() + // Store in in-memory cache + a.cacheMutex.Lock() + a.resultCache[cacheKey] = result + a.cacheMutex.Unlock() - // Store in Redis cache if available - if a.cache != nil { - if err := a.cache.Set(ctx, "text_analysis:"+cacheKey, result, 0); err != nil { - logger.LogWarn("Failed to cache text analysis result", - logger.F("language", language), - logger.F("textLength", len(text)), - logger.F("error", err)) - } - } - } + // Store in Redis cache if available + if a.cache != nil { + if err := a.cache.Set(ctx, "text_analysis:"+cacheKey, result, 0); err != nil { + log.LogWarn("Failed to cache text analysis result", + log.F("language", language), + log.F("textLength", len(text)), + log.F("error", err)) + } + } + } - return result, nil + return result, nil } // AnalyzeWork performs linguistic analysis on a work and stores the results func (a *BasicAnalyzer) AnalyzeWork(ctx context.Context, workID uint) error { - // Delegate to the WorkAnalysisService to preserve single ownership - return a.workAnalysisService.AnalyzeWork(ctx, workID) + // Delegate to the WorkAnalysisService to preserve single ownership + return a.workAnalysisService.AnalyzeWork(ctx, workID) } // Helper functions for text analysis @@ -174,6 +175,6 @@ func min(a, b int) int { // makeTextCacheKey builds a stable cache key using a content hash to avoid collisions/leaks func makeTextCacheKey(language, text string) string { - h := sha256.Sum256([]byte(text)) - return language + ":" + hex.EncodeToString(h[:]) + h := sha256.Sum256([]byte(text)) + return language + ":" + hex.EncodeToString(h[:]) } diff --git a/linguistics/factory.go b/linguistics/factory.go index 6e4fae7..fea6da2 100644 --- a/linguistics/factory.go +++ b/linguistics/factory.go @@ -1,9 +1,10 @@ package linguistics import ( - "gorm.io/gorm" - "tercul/cache" - "tercul/config" + "tercul/internal/platform/cache" + "tercul/internal/platform/config" + + "gorm.io/gorm" ) // LinguisticsFactory provides easy access to all linguistics components @@ -22,29 +23,29 @@ func NewLinguisticsFactory( concurrency int, cacheEnabled bool, ) *LinguisticsFactory { - // Create text analyzer and wire providers (prefer external libs when available) - textAnalyzer := NewBasicTextAnalyzer() + // Create text analyzer and wire providers (prefer external libs when available) + textAnalyzer := NewBasicTextAnalyzer() - // Wire sentiment provider: GoVADER (configurable) - if config.Cfg.NLPUseVADER { - if sp, err := NewGoVADERSentimentProvider(); err == nil { - textAnalyzer = textAnalyzer.WithSentimentProvider(sp) - } else { - textAnalyzer = textAnalyzer.WithSentimentProvider(RuleBasedSentimentProvider{}) - } - } else { - textAnalyzer = textAnalyzer.WithSentimentProvider(RuleBasedSentimentProvider{}) - } + // Wire sentiment provider: GoVADER (configurable) + if config.Cfg.NLPUseVADER { + if sp, err := NewGoVADERSentimentProvider(); err == nil { + textAnalyzer = textAnalyzer.WithSentimentProvider(sp) + } else { + textAnalyzer = textAnalyzer.WithSentimentProvider(RuleBasedSentimentProvider{}) + } + } else { + textAnalyzer = textAnalyzer.WithSentimentProvider(RuleBasedSentimentProvider{}) + } - // Wire language detector: lingua-go (configurable) - if config.Cfg.NLPUseLingua { - textAnalyzer = textAnalyzer.WithLanguageDetector(NewLinguaLanguageDetector()) - } + // Wire language detector: lingua-go (configurable) + if config.Cfg.NLPUseLingua { + textAnalyzer = textAnalyzer.WithLanguageDetector(NewLinguaLanguageDetector()) + } - // Wire keyword provider: lightweight TF-IDF approximation (configurable) - if config.Cfg.NLPUseTFIDF { - textAnalyzer = textAnalyzer.WithKeywordProvider(NewTFIDFKeywordProvider()) - } + // Wire keyword provider: lightweight TF-IDF approximation (configurable) + if config.Cfg.NLPUseTFIDF { + textAnalyzer = textAnalyzer.WithKeywordProvider(NewTFIDFKeywordProvider()) + } // Create cache components memoryCache := NewMemoryAnalysisCache(cacheEnabled) @@ -64,13 +65,13 @@ func NewLinguisticsFactory( ) // Create analyzer that combines text analysis and work analysis - analyzer := NewBasicAnalyzer( - textAnalyzer, - workAnalysisService, - cache, - concurrency, - cacheEnabled, - ) + analyzer := NewBasicAnalyzer( + textAnalyzer, + workAnalysisService, + cache, + concurrency, + cacheEnabled, + ) return &LinguisticsFactory{ textAnalyzer: textAnalyzer, diff --git a/linguistics/factory_test.go b/linguistics/factory_test.go index 2896e30..2496aaa 100644 --- a/linguistics/factory_test.go +++ b/linguistics/factory_test.go @@ -1,15 +1,13 @@ package linguistics import ( - "testing" - "github.com/stretchr/testify/require" + "github.com/stretchr/testify/require" + "testing" ) func TestFactory_WiresProviders(t *testing.T) { - // We won't spin a DB/cache here; this is a smoke test of wiring methods - f := NewLinguisticsFactory(nil, nil, 2, true) - ta := f.GetTextAnalyzer().(*BasicTextAnalyzer) - require.NotNil(t, ta) + // We won't spin a DB/cache here; this is a smoke test of wiring methods + f := NewLinguisticsFactory(nil, nil, 2, true) + ta := f.GetTextAnalyzer().(*BasicTextAnalyzer) + require.NotNil(t, ta) } - - diff --git a/linguistics/ports.go b/linguistics/ports.go index 070f978..cf28513 100644 --- a/linguistics/ports.go +++ b/linguistics/ports.go @@ -2,20 +2,18 @@ package linguistics // LanguageDetector defines a provider that can detect the language of a text type LanguageDetector interface { - // DetectLanguage returns a BCP-47 or ISO-like code and whether detection was confident - DetectLanguage(text string) (string, bool) + // DetectLanguage returns a BCP-47 or ISO-like code and whether detection was confident + DetectLanguage(text string) (string, bool) } // SentimentProvider defines a provider that scores sentiment in [-1, 1] type SentimentProvider interface { - // Score returns sentiment for the text (optionally using language) - Score(text string, language string) (float64, error) + // Score returns sentiment for the text (optionally using language) + Score(text string, language string) (float64, error) } // KeywordProvider defines a provider that extracts keywords from text type KeywordProvider interface { - // Extract returns a list of keywords with relevance in [0,1] - Extract(text string, language string) ([]Keyword, error) + // Extract returns a list of keywords with relevance in [0,1] + Extract(text string, language string) ([]Keyword, error) } - - diff --git a/linguistics/sync_job.go b/linguistics/sync_job.go index 24eb7b6..a011eb5 100644 --- a/linguistics/sync_job.go +++ b/linguistics/sync_job.go @@ -5,11 +5,11 @@ import ( "encoding/json" "fmt" "log" + models2 "tercul/internal/models" "time" "github.com/hibiken/asynq" "gorm.io/gorm" - "tercul/models" ) const ( @@ -60,7 +60,7 @@ func (j *LinguisticSyncJob) EnqueueAnalysisForAllWorks() error { log.Println("Enqueueing linguistic analysis jobs for all works...") var workIDs []uint - if err := j.DB.Model(&models.Work{}).Pluck("id", &workIDs).Error; err != nil { + if err := j.DB.Model(&models2.Work{}).Pluck("id", &workIDs).Error; err != nil { return fmt.Errorf("error fetching work IDs: %w", err) } @@ -87,7 +87,7 @@ func (j *LinguisticSyncJob) HandleLinguisticAnalysis(ctx context.Context, t *asy // Check if analysis already exists var count int64 - if err := j.DB.Model(&models.LanguageAnalysis{}).Where("work_id = ?", payload.WorkID).Count(&count).Error; err != nil { + if err := j.DB.Model(&models2.LanguageAnalysis{}).Where("work_id = ?", payload.WorkID).Count(&count).Error; err != nil { return fmt.Errorf("error checking existing analysis: %w", err) } diff --git a/linguistics/text_analyzer.go b/linguistics/text_analyzer.go index 293b183..80e9c07 100644 --- a/linguistics/text_analyzer.go +++ b/linguistics/text_analyzer.go @@ -15,33 +15,33 @@ type TextAnalyzer interface { } // BasicTextAnalyzer implements the TextAnalyzer interface with simple algorithms -type BasicTextAnalyzer struct{ - langDetector LanguageDetector - sentimentProvider SentimentProvider - keywordProvider KeywordProvider +type BasicTextAnalyzer struct { + langDetector LanguageDetector + sentimentProvider SentimentProvider + keywordProvider KeywordProvider } // NewBasicTextAnalyzer creates a new BasicTextAnalyzer func NewBasicTextAnalyzer() *BasicTextAnalyzer { - return &BasicTextAnalyzer{} + return &BasicTextAnalyzer{} } // WithLanguageDetector injects a language detector provider func (a *BasicTextAnalyzer) WithLanguageDetector(detector LanguageDetector) *BasicTextAnalyzer { - a.langDetector = detector - return a + a.langDetector = detector + return a } // WithSentimentProvider injects a sentiment provider func (a *BasicTextAnalyzer) WithSentimentProvider(provider SentimentProvider) *BasicTextAnalyzer { - a.sentimentProvider = provider - return a + a.sentimentProvider = provider + return a } // WithKeywordProvider injects a keyword provider func (a *BasicTextAnalyzer) WithKeywordProvider(provider KeywordProvider) *BasicTextAnalyzer { - a.keywordProvider = provider - return a + a.keywordProvider = provider + return a } // AnalyzeText performs linguistic analysis on the given text @@ -50,12 +50,12 @@ func (a *BasicTextAnalyzer) AnalyzeText(ctx context.Context, text string, langua return &AnalysisResult{}, nil } - // Auto-detect language if not provided and a detector exists - if language == "" && a.langDetector != nil { - if detected, ok := a.langDetector.DetectLanguage(text); ok { - language = detected - } - } + // Auto-detect language if not provided and a detector exists + if language == "" && a.langDetector != nil { + if detected, ok := a.langDetector.DetectLanguage(text); ok { + language = detected + } + } result := &AnalysisResult{ PartOfSpeechCounts: make(map[string]int), @@ -81,27 +81,27 @@ func (a *BasicTextAnalyzer) AnalyzeText(ctx context.Context, text string, langua result.ReadabilityScore = calculateReadabilityScore(result.AvgSentenceLength, result.AvgWordLength) result.ReadabilityMethod = "Simplified Flesch-Kincaid" - // Extract keywords: prefer provider if available - if a.keywordProvider != nil { - if kws, err := a.keywordProvider.Extract(text, language); err == nil { - result.Keywords = kws - } else { - result.Keywords = extractKeywordsOptimized(text, language) - } - } else { - result.Keywords = extractKeywordsOptimized(text, language) - } + // Extract keywords: prefer provider if available + if a.keywordProvider != nil { + if kws, err := a.keywordProvider.Extract(text, language); err == nil { + result.Keywords = kws + } else { + result.Keywords = extractKeywordsOptimized(text, language) + } + } else { + result.Keywords = extractKeywordsOptimized(text, language) + } - // Sentiment: prefer provider if available - if a.sentimentProvider != nil { - if score, err := a.sentimentProvider.Score(text, language); err == nil { - result.Sentiment = score - } else { - result.Sentiment = estimateSentimentOptimized(text, language) - } - } else { - result.Sentiment = estimateSentimentOptimized(text, language) - } + // Sentiment: prefer provider if available + if a.sentimentProvider != nil { + if score, err := a.sentimentProvider.Score(text, language); err == nil { + result.Sentiment = score + } else { + result.Sentiment = estimateSentimentOptimized(text, language) + } + } else { + result.Sentiment = estimateSentimentOptimized(text, language) + } return result, nil } @@ -112,29 +112,37 @@ func (a *BasicTextAnalyzer) AnalyzeTextConcurrently(ctx context.Context, text st return &AnalysisResult{}, nil } + // Auto-detect language if not provided and a detector exists + if language == "" && a.langDetector != nil { + if detected, ok := a.langDetector.DetectLanguage(text); ok { + language = detected + } + } + // Split the text into chunks for concurrent processing chunks := splitTextIntoChunks(text, concurrency) + n := len(chunks) - // Create channels for results - wordCountCh := make(chan int, concurrency) - sentenceCountCh := make(chan int, concurrency) - paragraphCountCh := make(chan int, concurrency) - wordLengthSumCh := make(chan float64, concurrency) - wordLengthCountCh := make(chan int, concurrency) - keywordsCh := make(chan []Keyword, concurrency) - sentimentCh := make(chan float64, concurrency) + // Create channels for results sized by actual chunks to avoid deadlocks + wordCountCh := make(chan int, n) + sentenceCountCh := make(chan int, n) + paragraphCountCh := make(chan int, n) + wordLengthSumCh := make(chan float64, n) + wordLengthCountCh := make(chan int, n) + keywordsCh := make(chan []Keyword, n) + sentimentCh := make(chan float64, n) // Process each chunk concurrently var wg sync.WaitGroup - for _, chunk := range chunks { + for _, chunk := range chunks { wg.Add(1) go func(chunkText string) { defer wg.Done() - select { - case <-ctx.Done(): - return - default: - } + select { + case <-ctx.Done(): + return + default: + } // Basic statistics words, sentences, paragraphs, wordLengthSum, wordCount := analyzeChunkBasicStats(chunkText) @@ -144,27 +152,27 @@ func (a *BasicTextAnalyzer) AnalyzeTextConcurrently(ctx context.Context, text st wordLengthSumCh <- wordLengthSum wordLengthCountCh <- wordCount - // Keywords (provider if available) - if a.keywordProvider != nil { - if kws, err := a.keywordProvider.Extract(chunkText, language); err == nil { - keywordsCh <- kws - } else { - keywordsCh <- extractKeywordsOptimized(chunkText, language) - } - } else { - keywordsCh <- extractKeywordsOptimized(chunkText, language) - } + // Keywords (provider if available) + if a.keywordProvider != nil { + if kws, err := a.keywordProvider.Extract(chunkText, language); err == nil { + keywordsCh <- kws + } else { + keywordsCh <- extractKeywordsOptimized(chunkText, language) + } + } else { + keywordsCh <- extractKeywordsOptimized(chunkText, language) + } - // Sentiment (provider if available) - if a.sentimentProvider != nil { - if score, err := a.sentimentProvider.Score(chunkText, language); err == nil { - sentimentCh <- score - } else { - sentimentCh <- estimateSentimentOptimized(chunkText, language) - } - } else { - sentimentCh <- estimateSentimentOptimized(chunkText, language) - } + // Sentiment (provider if available) + if a.sentimentProvider != nil { + if score, err := a.sentimentProvider.Score(chunkText, language); err == nil { + sentimentCh <- score + } else { + sentimentCh <- estimateSentimentOptimized(chunkText, language) + } + } else { + sentimentCh <- estimateSentimentOptimized(chunkText, language) + } }(chunk) } @@ -190,11 +198,9 @@ func (a *BasicTextAnalyzer) AnalyzeTextConcurrently(ctx context.Context, text st for wc := range wordCountCh { result.WordCount += wc } - for sc := range sentenceCountCh { result.SentenceCount += sc } - for pc := range paragraphCountCh { result.ParagraphCount += pc } @@ -208,7 +214,6 @@ func (a *BasicTextAnalyzer) AnalyzeTextConcurrently(ctx context.Context, text st for wlc := range wordLengthCountCh { totalWordCount += wlc } - if totalWordCount > 0 { result.AvgWordLength = totalWordLengthSum / float64(totalWordCount) } @@ -223,19 +228,19 @@ func (a *BasicTextAnalyzer) AnalyzeTextConcurrently(ctx context.Context, text st result.ReadabilityMethod = "Simplified Flesch-Kincaid" // Merge keywords - keywordMap := make(map[string]float64) + keywordSum := make(map[string]float64) + keywordCount := make(map[string]int) for kws := range keywordsCh { for _, kw := range kws { - keywordMap[kw.Text] += kw.Relevance + keywordSum[kw.Text] += kw.Relevance + keywordCount[kw.Text]++ } } - - // Convert keyword map to slice - for text, relevance := range keywordMap { - result.Keywords = append(result.Keywords, Keyword{ - Text: text, - Relevance: relevance / float64(concurrency), // Average relevance - }) + for text, sum := range keywordSum { + cnt := keywordCount[text] + if cnt > 0 { + result.Keywords = append(result.Keywords, Keyword{Text: text, Relevance: sum / float64(cnt)}) + } } // Average sentiment @@ -245,7 +250,6 @@ func (a *BasicTextAnalyzer) AnalyzeTextConcurrently(ctx context.Context, text st totalSentiment += s sentimentCount++ } - if sentimentCount > 0 { result.Sentiment = totalSentiment / float64(sentimentCount) } diff --git a/linguistics/text_analyzer_test.go b/linguistics/text_analyzer_test.go new file mode 100644 index 0000000..b39949b --- /dev/null +++ b/linguistics/text_analyzer_test.go @@ -0,0 +1,146 @@ +package linguistics + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Mocks for provider interfaces + +type mockLangDetector struct{ lang string; ok bool } +func (m mockLangDetector) DetectLanguage(text string) (string, bool) { return m.lang, m.ok } + +type mockSentimentProvider struct{ score float64; err error } +func (m mockSentimentProvider) Score(text string, language string) (float64, error) { return m.score, m.err } + +type mockKeywordProvider struct{ kws []Keyword; err error } +func (m mockKeywordProvider) Extract(text string, language string) ([]Keyword, error) { return m.kws, m.err } + +func TestAnalyzeText_Empty(t *testing.T) { + a := NewBasicTextAnalyzer() + res, err := a.AnalyzeText(context.Background(), "", "") + require.NoError(t, err) + assert.NotNil(t, res) + assert.Equal(t, 0, res.WordCount) + assert.Equal(t, 0, res.SentenceCount) + assert.Equal(t, 0.0, res.Sentiment) + assert.Len(t, res.Keywords, 0) +} + +func TestAnalyzeText_ProvidersAndLangDetection(t *testing.T) { + // Arrange + a := NewBasicTextAnalyzer(). + WithLanguageDetector(mockLangDetector{lang: "en", ok: true}). + WithSentimentProvider(mockSentimentProvider{score: 0.75}). + WithKeywordProvider(mockKeywordProvider{kws: []Keyword{{Text: "golang", Relevance: 0.42}}}) + + text := "Go is great. Go makes concurrency easier." + + // Act + res, err := a.AnalyzeText(context.Background(), text, "") + + // Assert + require.NoError(t, err) + require.NotNil(t, res) + assert.InDelta(t, 0.75, res.Sentiment, 1e-9) + require.Len(t, res.Keywords, 1) + assert.Equal(t, "golang", res.Keywords[0].Text) + assert.InDelta(t, 0.42, res.Keywords[0].Relevance, 1e-9) + // Basic stats make sense + assert.Greater(t, res.WordCount, 0) + assert.Greater(t, res.SentenceCount, 0) + // Readability is clamped to [0,100] + assert.GreaterOrEqual(t, res.ReadabilityScore, 0.0) + assert.LessOrEqual(t, res.ReadabilityScore, 100.0) + assert.Equal(t, "Simplified Flesch-Kincaid", res.ReadabilityMethod) +} + +func TestAnalyzeText_FallbackOnProviderError(t *testing.T) { + // Arrange providers that fail so analyzer uses internal fallbacks + a := NewBasicTextAnalyzer(). + WithSentimentProvider(mockSentimentProvider{err: errors.New("boom")}). + WithKeywordProvider(mockKeywordProvider{err: errors.New("boom")}) + + text := "I love good code but hate terrible bugs." + + // Act + res, err := a.AnalyzeText(context.Background(), text, "en") + + // Assert + require.NoError(t, err) + require.NotNil(t, res) + // Fallback sentiment should be between -1 and 1; with mixed words it should be non-zero + assert.GreaterOrEqual(t, res.Sentiment, -1.0) + assert.LessOrEqual(t, res.Sentiment, 1.0) + // Keywords should come from fallback extractor and be non-empty for this text + assert.NotEmpty(t, res.Keywords) +} + +func TestAnalyzeTextConcurrently_AggregatesWithProviders(t *testing.T) { + // Providers return consistent values regardless of input + kw := []Keyword{{Text: "constant", Relevance: 0.3}} + a := NewBasicTextAnalyzer(). + WithLanguageDetector(mockLangDetector{lang: "en", ok: true}). + WithSentimentProvider(mockSentimentProvider{score: 0.5}). + WithKeywordProvider(mockKeywordProvider{kws: kw}) + + text := "One sentence. Another sentence! And a question? Final one." + + // Act + _, err1 := a.AnalyzeText(context.Background(), text, "") + conc, err2 := a.AnalyzeTextConcurrently(context.Background(), text, "", 3) + + // Assert + require.NoError(t, err1) + require.NoError(t, err2) + + // Basic stats: should be sane + assert.Greater(t, conc.WordCount, 0) + assert.GreaterOrEqual(t, conc.SentenceCount, 0) + assert.GreaterOrEqual(t, conc.ParagraphCount, 1) + assert.GreaterOrEqual(t, conc.AvgWordLength, 0.0) + assert.GreaterOrEqual(t, conc.AvgSentenceLength, 0.0) + + // Readability is clamped to [0,100] + assert.GreaterOrEqual(t, conc.ReadabilityScore, 0.0) + assert.LessOrEqual(t, conc.ReadabilityScore, 100.0) + assert.Equal(t, "Simplified Flesch-Kincaid", conc.ReadabilityMethod) + + // Provider-driven outputs should align + assert.InDelta(t, 0.5, conc.Sentiment, 1e-9) + require.Len(t, conc.Keywords, 1) + assert.Equal(t, "constant", conc.Keywords[0].Text) + assert.InDelta(t, 0.3, conc.Keywords[0].Relevance, 1e-9) +} + +func TestAnalyzeTextConcurrently_ContextCanceled(t *testing.T) { + a := NewBasicTextAnalyzer(). + WithLanguageDetector(mockLangDetector{lang: "en", ok: true}). + WithSentimentProvider(mockSentimentProvider{score: 0.9}). + WithKeywordProvider(mockKeywordProvider{kws: []Keyword{{Text: "x", Relevance: 0.1}}}) + + text := "This should not be processed. Another sentence. And one more." + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately before processing + + conc, err := a.AnalyzeTextConcurrently(ctx, text, "", 4) + require.NoError(t, err) + require.NotNil(t, conc) + + // With immediate cancellation, goroutines should early-return and no values should be sent + assert.Equal(t, 0, conc.WordCount) + assert.Equal(t, 0, conc.SentenceCount) + assert.Equal(t, 0, conc.ParagraphCount) + assert.Equal(t, 0.0, conc.AvgWordLength) + assert.Equal(t, 0.0, conc.AvgSentenceLength) + // Readability is clamped [0,100]; with zero stats it becomes 100 + assert.GreaterOrEqual(t, conc.ReadabilityScore, 0.0) + assert.LessOrEqual(t, conc.ReadabilityScore, 100.0) + assert.Empty(t, conc.Keywords) + assert.Equal(t, 0.0, conc.Sentiment) +} diff --git a/linguistics/text_utils.go b/linguistics/text_utils.go index 1d84e42..0a3257e 100644 --- a/linguistics/text_utils.go +++ b/linguistics/text_utils.go @@ -1,41 +1,41 @@ package linguistics import ( - "strings" - "unicode" - "sort" + "sort" + "strings" + "unicode" ) // Precomputed lexical resources for fast lookups var ( - stopWordsEN = map[string]struct{}{ - "the": {}, "a": {}, "an": {}, "and": {}, "or": {}, "but": {}, - "in": {}, "on": {}, "at": {}, "to": {}, "for": {}, "of": {}, - "with": {}, "by": {}, "is": {}, "are": {}, "was": {}, "were": {}, - "be": {}, "been": {}, "being": {}, "have": {}, "has": {}, "had": {}, - "do": {}, "does": {}, "did": {}, "will": {}, "would": {}, "could": {}, - "should": {}, "may": {}, "might": {}, "can": {}, "this": {}, "that": {}, - "these": {}, "those": {}, "i": {}, "you": {}, "he": {}, "she": {}, - "it": {}, "we": {}, "they": {}, "me": {}, "him": {}, "hers": {}, - "us": {}, "them": {}, "my": {}, "your": {}, "his": {}, "its": {}, - "our": {}, "their": {}, - } + stopWordsEN = map[string]struct{}{ + "the": {}, "a": {}, "an": {}, "and": {}, "or": {}, "but": {}, + "in": {}, "on": {}, "at": {}, "to": {}, "for": {}, "of": {}, + "with": {}, "by": {}, "is": {}, "are": {}, "was": {}, "were": {}, + "be": {}, "been": {}, "being": {}, "have": {}, "has": {}, "had": {}, + "do": {}, "does": {}, "did": {}, "will": {}, "would": {}, "could": {}, + "should": {}, "may": {}, "might": {}, "can": {}, "this": {}, "that": {}, + "these": {}, "those": {}, "i": {}, "you": {}, "he": {}, "she": {}, + "it": {}, "we": {}, "they": {}, "me": {}, "him": {}, "hers": {}, + "us": {}, "them": {}, "my": {}, "your": {}, "his": {}, "its": {}, + "our": {}, "their": {}, + } - positiveEN = map[string]struct{}{ - "good": {}, "great": {}, "excellent": {}, "amazing": {}, "wonderful": {}, - "beautiful": {}, "love": {}, "happy": {}, "joy": {}, "success": {}, - "win": {}, "winning": {}, "best": {}, "perfect": {}, "fantastic": {}, - "brilliant": {}, "outstanding": {}, "superb": {}, "magnificent": {}, - "delightful": {}, "pleasure": {}, "enjoy": {}, "enjoyable": {}, - } + positiveEN = map[string]struct{}{ + "good": {}, "great": {}, "excellent": {}, "amazing": {}, "wonderful": {}, + "beautiful": {}, "love": {}, "happy": {}, "joy": {}, "success": {}, + "win": {}, "winning": {}, "best": {}, "perfect": {}, "fantastic": {}, + "brilliant": {}, "outstanding": {}, "superb": {}, "magnificent": {}, + "delightful": {}, "pleasure": {}, "enjoy": {}, "enjoyable": {}, + } - negativeEN = map[string]struct{}{ - "bad": {}, "terrible": {}, "awful": {}, "horrible": {}, "disgusting": {}, - "hate": {}, "sad": {}, "angry": {}, "furious": {}, "disappointed": {}, - "fail": {}, "failure": {}, "lose": {}, "losing": {}, "worst": {}, - "dreadful": {}, "miserable": {}, "painful": {}, - "annoying": {}, "frustrating": {}, "upset": {}, "depressed": {}, - } + negativeEN = map[string]struct{}{ + "bad": {}, "terrible": {}, "awful": {}, "horrible": {}, "disgusting": {}, + "hate": {}, "sad": {}, "angry": {}, "furious": {}, "disappointed": {}, + "fail": {}, "failure": {}, "lose": {}, "losing": {}, "worst": {}, + "dreadful": {}, "miserable": {}, "painful": {}, + "annoying": {}, "frustrating": {}, "upset": {}, "depressed": {}, + } ) // analyzeTextBasicStats performs basic text statistics analysis @@ -44,58 +44,11 @@ func analyzeTextBasicStats(text string) (words, sentences, paragraphs int, avgWo return 0, 0, 0, 0 } - // Single pass scanner over runes - inWord := false - wordLen := 0 - totalWordLen := 0 - words = 0 - sentences = 0 - paragraphs = 1 - - prevWasNewline := false - - for _, r := range text { - // Paragraphs: count double newline boundaries - if r == '\n' { - if prevWasNewline { - paragraphs++ - prevWasNewline = false // avoid counting more than once for >2 newlines - } else { - prevWasNewline = true - } - } else { - prevWasNewline = false - } - - // Sentences: simple heuristic on end punctuation - if r == '.' || r == '!' || r == '?' { - sentences++ - } - - // Words: alphanumeric sequences - if unicode.IsLetter(r) || unicode.IsNumber(r) { - inWord = true - wordLen++ - } else { - if inWord { - words++ - totalWordLen += wordLen - inWord = false - wordLen = 0 - } - } - } - - if inWord { - words++ - totalWordLen += wordLen - } - - if words > 0 { - avgWordLength = float64(totalWordLen) / float64(words) - } - - return words, sentences, paragraphs, avgWordLength + words, sentences, paragraphs, totalWordLen := scanTextStats(text) + if words > 0 { + avgWordLength = float64(totalWordLen) / float64(words) + } + return words, sentences, paragraphs, avgWordLength } // analyzeChunkBasicStats performs basic statistics on a text chunk @@ -104,52 +57,50 @@ func analyzeChunkBasicStats(chunk string) (words, sentences, paragraphs int, wor return 0, 0, 0, 0, 0 } - inWord := false - wordLen := 0 - totalWordLen := 0 - words = 0 - sentences = 0 - paragraphs = 1 + words, sentences, paragraphs, totalWordLen := scanTextStats(chunk) + return words, sentences, paragraphs, float64(totalWordLen), words +} - prevWasNewline := false - - for _, r := range chunk { - if r == '\n' { - if prevWasNewline { - paragraphs++ - prevWasNewline = false - } else { - prevWasNewline = true - } - } else { - prevWasNewline = false - } - - if r == '.' || r == '!' || r == '?' { - sentences++ - } - - if unicode.IsLetter(r) || unicode.IsNumber(r) { - inWord = true - wordLen++ - } else { - if inWord { - words++ - totalWordLen += wordLen - inWord = false - wordLen = 0 - } - } - } - - if inWord { - words++ - totalWordLen += wordLen - } - - wordLengthSum = float64(totalWordLen) - wordCount = words - return words, sentences, paragraphs, wordLengthSum, wordCount +// scanTextStats is a shared core that scans text and returns words, sentences, paragraphs and totalWordLen +func scanTextStats[T ~string](text T) (words int, sentences int, paragraphs int, totalWordLen int) { + if len(text) == 0 { + return 0, 0, 0, 0 + } + inWord := false + wordLen := 0 + words = 0 + sentences = 0 + paragraphs = 1 + prevWasNewline := false + for _, r := range text { + if r == '\n' { + if prevWasNewline { + paragraphs++ + prevWasNewline = false + } else { + prevWasNewline = true + } + } else { + prevWasNewline = false + } + if r == '.' || r == '!' || r == '?' { + sentences++ + } + if unicode.IsLetter(r) || unicode.IsNumber(r) { + inWord = true + wordLen++ + } else if inWord { + words++ + totalWordLen += wordLen + inWord = false + wordLen = 0 + } + } + if inWord { + words++ + totalWordLen += wordLen + } + return } // splitTextIntoChunks splits text into chunks for concurrent processing @@ -163,14 +114,14 @@ func splitTextIntoChunks(text string, numChunks int) []string { return r == '.' || r == '!' || r == '?' }) - if len(sentences) == 0 { - return []string{text} - } + if len(sentences) == 0 { + return []string{text} + } - if numChunks > len(sentences) { - numChunks = len(sentences) - } - chunks := make([]string, numChunks) + if numChunks > len(sentences) { + numChunks = len(sentences) + } + chunks := make([]string, numChunks) chunkSize := len(sentences) / numChunks remainder := len(sentences) % numChunks @@ -215,33 +166,33 @@ func extractKeywordsOptimized(text, language string) []Keyword { return []Keyword{} } - tokens := tokenizeWords(text) - if len(tokens) == 0 { - return []Keyword{} - } + tokens := tokenizeWords(text) + if len(tokens) == 0 { + return []Keyword{} + } - wordFreq := make(map[string]int, len(tokens)) - for _, tok := range tokens { - if len(tok) > 2 && !isStopWord(tok, language) { - wordFreq[tok]++ - } - } + wordFreq := make(map[string]int, len(tokens)) + for _, tok := range tokens { + if len(tok) > 2 && !isStopWord(tok, language) { + wordFreq[tok]++ + } + } - total := len(tokens) - keywords := make([]Keyword, 0, len(wordFreq)) - for word, freq := range wordFreq { - relevance := float64(freq) / float64(total) - if relevance > 0.01 { - keywords = append(keywords, Keyword{Text: word, Relevance: relevance}) - } - } + total := len(tokens) + keywords := make([]Keyword, 0, len(wordFreq)) + for word, freq := range wordFreq { + relevance := float64(freq) / float64(total) + if relevance > 0.01 { + keywords = append(keywords, Keyword{Text: word, Relevance: relevance}) + } + } - sort.Slice(keywords, func(i, j int) bool { return keywords[i].Relevance > keywords[j].Relevance }) - if len(keywords) > 10 { - keywords = keywords[:10] - } + sort.Slice(keywords, func(i, j int) bool { return keywords[i].Relevance > keywords[j].Relevance }) + if len(keywords) > 10 { + keywords = keywords[:10] + } - return keywords + return keywords } // estimateSentimentOptimized estimates sentiment using a simplified approach @@ -250,16 +201,16 @@ func estimateSentimentOptimized(text, language string) float64 { return 0 } - tokens := tokenizeWords(text) - positiveCount := 0 - negativeCount := 0 - for _, tok := range tokens { - if isPositiveWord(tok, language) { - positiveCount++ - } else if isNegativeWord(tok, language) { - negativeCount++ - } - } + tokens := tokenizeWords(text) + positiveCount := 0 + negativeCount := 0 + for _, tok := range tokens { + if isPositiveWord(tok, language) { + positiveCount++ + } else if isNegativeWord(tok, language) { + negativeCount++ + } + } total := positiveCount + negativeCount if total == 0 { @@ -272,58 +223,59 @@ func estimateSentimentOptimized(text, language string) float64 { // isStopWord checks if a word is a common stop word func isStopWord(word, language string) bool { - switch language { - case "en": - _, ok := stopWordsEN[word] - return ok - default: - _, ok := stopWordsEN[word] - return ok - } + switch language { + case "en": + _, ok := stopWordsEN[word] + return ok + default: + _, ok := stopWordsEN[word] + return ok + } } // isPositiveWord checks if a word is positive func isPositiveWord(word, language string) bool { - switch language { - case "en": - _, ok := positiveEN[word] - return ok - default: - _, ok := positiveEN[word] - return ok - } + switch language { + case "en": + _, ok := positiveEN[word] + return ok + default: + _, ok := positiveEN[word] + return ok + } } // isNegativeWord checks if a word is negative func isNegativeWord(word, language string) bool { - switch language { - case "en": - _, ok := negativeEN[word] - return ok - default: - _, ok := negativeEN[word] - return ok - } + switch language { + case "en": + _, ok := negativeEN[word] + return ok + default: + _, ok := negativeEN[word] + return ok + } } // tokenizeWords returns lowercase alphanumeric tokens using a single pass func tokenizeWords(text string) []string { - if text == "" { - return nil - } - tokens := make([]string, 0, 256) - var b strings.Builder - b.Grow(32) - for _, r := range strings.ToLower(text) { - if unicode.IsLetter(r) || unicode.IsNumber(r) { - b.WriteRune(r) - } else if b.Len() > 0 { - tokens = append(tokens, b.String()) - b.Reset() - } - } - if b.Len() > 0 { - tokens = append(tokens, b.String()) - } - return tokens + if text == "" { + return nil + } + tokens := make([]string, 0, 256) + var b strings.Builder + b.Grow(32) + for _, r := range text { + lr := unicode.ToLower(r) + if unicode.IsLetter(lr) || unicode.IsNumber(lr) { + b.WriteRune(lr) + } else if b.Len() > 0 { + tokens = append(tokens, b.String()) + b.Reset() + } + } + if b.Len() > 0 { + tokens = append(tokens, b.String()) + } + return tokens } diff --git a/linguistics/text_utils_test.go b/linguistics/text_utils_test.go index 5022298..675c0d7 100644 --- a/linguistics/text_utils_test.go +++ b/linguistics/text_utils_test.go @@ -17,11 +17,11 @@ func TestAnalyzeTextBasicStats(t *testing.T) { } func TestSplitTextIntoChunks(t *testing.T) { - text := "A. B. C. D. E." - chunks := splitTextIntoChunks(text, 10) - // should not over-provision chunks - assert.GreaterOrEqual(t, len(chunks), 1) - assert.LessOrEqual(t, len(chunks), 5) + text := "A. B. C. D. E." + chunks := splitTextIntoChunks(text, 10) + // should not over-provision chunks + assert.GreaterOrEqual(t, len(chunks), 1) + assert.LessOrEqual(t, len(chunks), 5) } func TestExtractKeywordsOptimized(t *testing.T) { diff --git a/linguistics/work_analysis_service.go b/linguistics/work_analysis_service.go index 4d7fbd5..1a164f9 100644 --- a/linguistics/work_analysis_service.go +++ b/linguistics/work_analysis_service.go @@ -3,9 +3,10 @@ package linguistics import ( "context" "fmt" - "time" - "tercul/logger" - "tercul/models" + "tercul/internal/models" + "time" + + "tercul/internal/platform/log" ) // WorkAnalysisService defines the interface for work-specific analysis operations @@ -76,8 +77,8 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro cacheKey := fmt.Sprintf("work_analysis:%d", workID) if result, err := s.analysisCache.Get(ctx, cacheKey); err == nil { - logger.LogInfo("Cache hit for work analysis", - logger.F("workID", workID)) + log.LogInfo("Cache hit for work analysis", + log.F("workID", workID)) // Store directly to database return s.analysisRepo.StoreAnalysisResults(ctx, workID, result) @@ -87,56 +88,56 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro // Get work content from database content, err := s.analysisRepo.GetWorkContent(ctx, workID, "") if err != nil { - logger.LogError("Failed to get work content for analysis", - logger.F("workID", workID), - logger.F("error", err)) + log.LogError("Failed to get work content for analysis", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to get work content: %w", err) } // Skip analysis if content is empty if content == "" { - logger.LogWarn("Skipping analysis for work with empty content", - logger.F("workID", workID)) + log.LogWarn("Skipping analysis for work with empty content", + log.F("workID", workID)) return nil } - // Get work to determine language (via repository to avoid leaking GORM) - work, err := s.analysisRepo.GetWorkByID(ctx, workID) - if err != nil { - logger.LogError("Failed to fetch work for analysis", - logger.F("workID", workID), - logger.F("error", err)) - return fmt.Errorf("failed to fetch work: %w", err) - } + // Get work to determine language (via repository to avoid leaking GORM) + work, err := s.analysisRepo.GetWorkByID(ctx, workID) + if err != nil { + log.LogError("Failed to fetch work for analysis", + log.F("workID", workID), + log.F("error", err)) + return fmt.Errorf("failed to fetch work: %w", err) + } - // Analyze the text - start := time.Now() - logger.LogInfo("Analyzing work", - logger.F("workID", workID), - logger.F("language", work.Language), - logger.F("contentLength", len(content))) + // Analyze the text + start := time.Now() + log.LogInfo("Analyzing work", + log.F("workID", workID), + log.F("language", work.Language), + log.F("contentLength", len(content))) var result *AnalysisResult // Use concurrent processing for large texts - if len(content) > 10000 && s.concurrency > 1 { - result, err = s.textAnalyzer.AnalyzeTextConcurrently(ctx, content, work.Language, s.concurrency) + if len(content) > 10000 && s.concurrency > 1 { + result, err = s.textAnalyzer.AnalyzeTextConcurrently(ctx, content, work.Language, s.concurrency) } else { - result, err = s.textAnalyzer.AnalyzeText(ctx, content, work.Language) + result, err = s.textAnalyzer.AnalyzeText(ctx, content, work.Language) } if err != nil { - logger.LogError("Failed to analyze work text", - logger.F("workID", workID), - logger.F("error", err)) + log.LogError("Failed to analyze work text", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to analyze work text: %w", err) } // Store results in database if err := s.analysisRepo.StoreAnalysisResults(ctx, workID, result); err != nil { - logger.LogError("Failed to store analysis results", - logger.F("workID", workID), - logger.F("error", err)) + log.LogError("Failed to store analysis results", + log.F("workID", workID), + log.F("error", err)) return fmt.Errorf("failed to store analysis results: %w", err) } @@ -144,18 +145,18 @@ func (s *workAnalysisService) AnalyzeWork(ctx context.Context, workID uint) erro if s.cacheEnabled && s.analysisCache.IsEnabled() { cacheKey := fmt.Sprintf("work_analysis:%d", workID) if err := s.analysisCache.Set(ctx, cacheKey, result); err != nil { - logger.LogWarn("Failed to cache work analysis result", - logger.F("workID", workID), - logger.F("error", err)) + log.LogWarn("Failed to cache work analysis result", + log.F("workID", workID), + log.F("error", err)) } } - logger.LogInfo("Successfully analyzed work", - logger.F("workID", workID), - logger.F("wordCount", result.WordCount), - logger.F("readabilityScore", result.ReadabilityScore), - logger.F("sentiment", result.Sentiment), - logger.F("durationMs", time.Since(start).Milliseconds())) + log.LogInfo("Successfully analyzed work", + log.F("workID", workID), + log.F("wordCount", result.WordCount), + log.F("readabilityScore", result.ReadabilityScore), + log.F("sentiment", result.Sentiment), + log.F("durationMs", time.Since(start).Milliseconds())) return nil } @@ -166,14 +167,14 @@ func (s *workAnalysisService) GetWorkAnalytics(ctx context.Context, workID uint) return nil, fmt.Errorf("invalid work ID") } - // Get the work to ensure it exists - work, err := s.analysisRepo.GetWorkByID(ctx, workID) - if err != nil { - return nil, fmt.Errorf("work not found: %w", err) - } + // Get the work to ensure it exists + work, err := s.analysisRepo.GetWorkByID(ctx, workID) + if err != nil { + return nil, fmt.Errorf("work not found: %w", err) + } // Get analysis results from database - _, readabilityScore, languageAnalysis, _ := s.analysisRepo.GetAnalysisData(ctx, workID) + _, readabilityScore, languageAnalysis, _ := s.analysisRepo.GetAnalysisData(ctx, workID) // Extract keywords from JSONB var keywords []string @@ -191,14 +192,14 @@ func (s *workAnalysisService) GetWorkAnalytics(ctx context.Context, workID uint) // For now, return placeholder analytics with actual analysis data return &WorkAnalytics{ - WorkID: work.ID, + WorkID: work.ID, ViewCount: 0, // TODO: Implement view counting LikeCount: 0, // TODO: Implement like counting CommentCount: 0, // TODO: Implement comment counting BookmarkCount: 0, // TODO: Implement bookmark counting TranslationCount: 0, // TODO: Implement translation counting - ReadabilityScore: readabilityScore.Score, - SentimentScore: extractSentimentFromAnalysis(languageAnalysis.Analysis), + ReadabilityScore: readabilityScore.Score, + SentimentScore: extractSentimentFromAnalysis(languageAnalysis.Analysis), TopKeywords: keywords, PopularTranslations: []TranslationAnalytics{}, // TODO: Implement translation analytics }, nil diff --git a/main.go b/main.go index 03e24cb..7bb6b3f 100644 --- a/main.go +++ b/main.go @@ -6,9 +6,9 @@ import ( "os" "os/signal" "syscall" - "tercul/config" "tercul/internal/app" - "tercul/logger" + "tercul/internal/platform/config" + "tercul/internal/platform/log" "time" "github.com/hibiken/asynq" @@ -22,16 +22,16 @@ func main() { config.LoadConfig() // Initialize structured logger with appropriate log level - logger.SetDefaultLevel(logger.InfoLevel) - logger.LogInfo("Starting Tercul application", - logger.F("environment", config.Cfg.Environment), - logger.F("version", "1.0.0")) + log.SetDefaultLevel(log.InfoLevel) + log.LogInfo("Starting Tercul application", + log.F("environment", config.Cfg.Environment), + log.F("version", "1.0.0")) // Build application components appBuilder := app.NewApplicationBuilder() if err := appBuilder.Build(); err != nil { - logger.LogFatal("Failed to build application", - logger.F("error", err)) + log.LogFatal("Failed to build application", + log.F("error", err)) } defer appBuilder.Close() @@ -41,46 +41,46 @@ func main() { // Create servers graphQLServer, err := serverFactory.CreateGraphQLServer() if err != nil { - logger.LogFatal("Failed to create GraphQL server", - logger.F("error", err)) + log.LogFatal("Failed to create GraphQL server", + log.F("error", err)) } backgroundServers, err := serverFactory.CreateBackgroundJobServers() if err != nil { - logger.LogFatal("Failed to create background job servers", - logger.F("error", err)) + log.LogFatal("Failed to create background job servers", + log.F("error", err)) } playgroundServer := serverFactory.CreatePlaygroundServer() // Start HTTP servers in goroutines go func() { - logger.LogInfo("Starting GraphQL server", - logger.F("port", config.Cfg.ServerPort)) + log.LogInfo("Starting GraphQL server", + log.F("port", config.Cfg.ServerPort)) if err := graphQLServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.LogFatal("Failed to start GraphQL server", - logger.F("error", err)) + log.LogFatal("Failed to start GraphQL server", + log.F("error", err)) } }() go func() { - logger.LogInfo("Starting GraphQL playground", - logger.F("port", config.Cfg.PlaygroundPort)) + log.LogInfo("Starting GraphQL playground", + log.F("port", config.Cfg.PlaygroundPort)) if err := playgroundServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - logger.LogFatal("Failed to start GraphQL playground", - logger.F("error", err)) + log.LogFatal("Failed to start GraphQL playground", + log.F("error", err)) } }() // Start background job servers in goroutines for i, server := range backgroundServers { go func(serverIndex int, srv *asynq.Server) { - logger.LogInfo("Starting background job server", - logger.F("serverIndex", serverIndex)) + log.LogInfo("Starting background job server", + log.F("serverIndex", serverIndex)) if err := srv.Run(asynq.NewServeMux()); err != nil { - logger.LogError("Background job server failed", - logger.F("serverIndex", serverIndex), - logger.F("error", err)) + log.LogError("Background job server failed", + log.F("serverIndex", serverIndex), + log.F("error", err)) } }(i, server) } @@ -90,28 +90,28 @@ func main() { signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) <-quit - logger.LogInfo("Shutting down servers...") + log.LogInfo("Shutting down servers...") // Graceful shutdown ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() if err := graphQLServer.Shutdown(ctx); err != nil { - logger.LogError("GraphQL server forced to shutdown", - logger.F("error", err)) + log.LogError("GraphQL server forced to shutdown", + log.F("error", err)) } if err := playgroundServer.Shutdown(ctx); err != nil { - logger.LogError("GraphQL playground forced to shutdown", - logger.F("error", err)) + log.LogError("GraphQL playground forced to shutdown", + log.F("error", err)) } // Shutdown background job servers for i, server := range backgroundServers { server.Shutdown() - logger.LogInfo("Background job server shutdown", - logger.F("serverIndex", i)) + log.LogInfo("Background job server shutdown", + log.F("serverIndex", i)) } - logger.LogInfo("All servers shutdown successfully") + log.LogInfo("All servers shutdown successfully") } diff --git a/scripts/analyze_data.py b/scripts/analyze_data.py deleted file mode 100644 index fc78b0b..0000000 --- a/scripts/analyze_data.py +++ /dev/null @@ -1,337 +0,0 @@ -#!/usr/bin/env python3 -""" -Data Analysis Script for Tercul Database Migration - -This script analyzes the current SQLite database to understand data quality, -relationships, and prepare for migration to the Go/GORM schema. -""" - -import sqlite3 -import json -import re -from datetime import datetime -from typing import Dict, List, Any, Optional -from dataclasses import dataclass -from pathlib import Path - -@dataclass -class TableStats: - """Statistics for a database table""" - name: str - record_count: int - sample_records: List[Dict[str, Any]] - column_info: List[Dict[str, Any]] - foreign_keys: List[Dict[str, Any]] - data_quality_issues: List[str] - -class DataAnalyzer: - def __init__(self, db_path: str): - self.db_path = db_path - self.conn = sqlite3.connect(db_path) - self.conn.row_factory = sqlite3.Row - self.stats: Dict[str, TableStats] = {} - - def __del__(self): - if hasattr(self, 'conn'): - self.conn.close() - - def get_table_names(self) -> List[str]: - """Get all table names from the database""" - cursor = self.conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - return [row[0] for row in cursor.fetchall()] - - def get_table_info(self, table_name: str) -> List[Dict[str, Any]]: - """Get column information for a table""" - cursor = self.conn.cursor() - cursor.execute(f"PRAGMA table_info({table_name})") - columns = [] - for row in cursor.fetchall(): - columns.append({ - 'name': row[1], - 'type': row[2], - 'not_null': bool(row[3]), - 'default_value': row[4], - 'primary_key': bool(row[5]) - }) - return columns - - def get_foreign_keys(self, table_name: str) -> List[Dict[str, Any]]: - """Get foreign key information for a table""" - cursor = self.conn.cursor() - cursor.execute(f"PRAGMA foreign_key_list({table_name})") - foreign_keys = [] - for row in cursor.fetchall(): - foreign_keys.append({ - 'id': row[0], - 'seq': row[1], - 'table': row[2], - 'from': row[3], - 'to': row[4], - 'on_update': row[5], - 'on_delete': row[6], - 'match': row[7] - }) - return foreign_keys - - def get_record_count(self, table_name: str) -> int: - """Get the number of records in a table""" - cursor = self.conn.cursor() - cursor.execute(f"SELECT COUNT(*) FROM {table_name}") - return cursor.fetchone()[0] - - def get_sample_records(self, table_name: str, limit: int = 3) -> List[Dict[str, Any]]: - """Get sample records from a table""" - cursor = self.conn.cursor() - cursor.execute(f"SELECT * FROM {table_name} LIMIT {limit}") - records = [] - for row in cursor.fetchall(): - record = {} - for key in row.keys(): - value = row[key] - # Convert to JSON-serializable format - if isinstance(value, datetime): - value = value.isoformat() - record[key] = value - records.append(record) - return records - - def analyze_uuid_format(self, table_name: str, id_column: str = 'id') -> List[str]: - """Analyze UUID format in ID columns""" - cursor = self.conn.cursor() - cursor.execute(f"SELECT {id_column} FROM {table_name} WHERE {id_column} IS NOT NULL LIMIT 100") - issues = [] - uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE) - - for row in cursor.fetchall(): - uuid_value = row[0] - if uuid_value and not uuid_pattern.match(str(uuid_value)): - issues.append(f"Invalid UUID format: {uuid_value}") - - return issues[:10] # Limit to first 10 issues - - def analyze_timestamp_format(self, table_name: str) -> List[str]: - """Analyze timestamp format in datetime columns""" - cursor = self.conn.cursor() - cursor.execute(f"PRAGMA table_info({table_name})") - timestamp_columns = [] - for row in cursor.fetchall(): - if 'time' in row[1].lower() or 'date' in row[1].lower() or 'at' in row[1].lower(): - timestamp_columns.append(row[1]) - - issues = [] - for column in timestamp_columns: - cursor.execute(f"SELECT {column} FROM {table_name} WHERE {column} IS NOT NULL LIMIT 50") - for row in cursor.fetchall(): - timestamp_value = row[0] - if timestamp_value: - try: - # Try to parse common timestamp formats - if isinstance(timestamp_value, str): - # Try different formats - formats = [ - '%Y-%m-%d %H:%M:%S', - '%Y-%m-%d %H:%M:%S.%f', - '%Y-%m-%d', - '%Y-%m-%dT%H:%M:%S', - '%Y-%m-%dT%H:%M:%S.%fZ' - ] - parsed = False - for fmt in formats: - try: - datetime.strptime(timestamp_value, fmt) - parsed = True - break - except ValueError: - continue - if not parsed: - issues.append(f"Invalid timestamp format in {column}: {timestamp_value}") - except Exception as e: - issues.append(f"Error parsing timestamp in {column}: {timestamp_value} - {e}") - - return issues[:10] # Limit to first 10 issues - - def analyze_relationships(self, table_name: str) -> List[str]: - """Analyze relationship integrity""" - cursor = self.conn.cursor() - cursor.execute(f"PRAGMA foreign_key_list({table_name})") - foreign_keys = cursor.fetchall() - - issues = [] - for fk in foreign_keys: - from_column = fk[3] - to_table = fk[2] - to_column = fk[4] - - # Check for orphaned references - cursor.execute(f""" - SELECT COUNT(*) FROM {table_name} t1 - LEFT JOIN {to_table} t2 ON t1.{from_column} = t2.{to_column} - WHERE t1.{from_column} IS NOT NULL AND t2.{to_column} IS NULL - """) - orphaned_count = cursor.fetchone()[0] - if orphaned_count > 0: - issues.append(f"Found {orphaned_count} orphaned references to {to_table}.{to_column}") - - return issues - - def analyze_content_format(self, table_name: str, content_columns: List[str]) -> List[str]: - """Analyze content format in text columns""" - cursor = self.conn.cursor() - issues = [] - - for column in content_columns: - cursor.execute(f"SELECT {column} FROM {table_name} WHERE {column} IS NOT NULL LIMIT 20") - for row in cursor.fetchall(): - content = row[0] - if content and isinstance(content, str): - # Check for YAML/Ruby format - if '--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess' in content: - issues.append(f"YAML/Ruby format detected in {column}") - - # Check for HTML content - if '

' in content or '

' in content: - issues.append(f"HTML content detected in {column}") - - # Check for very long content - if len(content) > 10000: - issues.append(f"Very long content in {column}: {len(content)} characters") - - return list(set(issues)) # Remove duplicates - - def analyze_table(self, table_name: str) -> TableStats: - """Comprehensive analysis of a single table""" - print(f"Analyzing table: {table_name}") - - # Basic information - record_count = self.get_record_count(table_name) - column_info = self.get_table_info(table_name) - foreign_keys = self.get_foreign_keys(table_name) - sample_records = self.get_sample_records(table_name) - - # Data quality analysis - data_quality_issues = [] - - # UUID analysis - uuid_issues = self.analyze_uuid_format(table_name) - data_quality_issues.extend(uuid_issues) - - # Timestamp analysis - timestamp_issues = self.analyze_timestamp_format(table_name) - data_quality_issues.extend(timestamp_issues) - - # Relationship analysis - relationship_issues = self.analyze_relationships(table_name) - data_quality_issues.extend(relationship_issues) - - # Content analysis for text columns - text_columns = [col['name'] for col in column_info if 'text' in col['type'].lower() or col['name'] in ['body', 'content', 'description']] - content_issues = self.analyze_content_format(table_name, text_columns) - data_quality_issues.extend(content_issues) - - stats = TableStats( - name=table_name, - record_count=record_count, - sample_records=sample_records, - column_info=column_info, - foreign_keys=foreign_keys, - data_quality_issues=data_quality_issues - ) - - self.stats[table_name] = stats - return stats - - def analyze_all_tables(self) -> Dict[str, TableStats]: - """Analyze all tables in the database""" - table_names = self.get_table_names() - - for table_name in table_names: - try: - self.analyze_table(table_name) - except Exception as e: - print(f"Error analyzing table {table_name}: {e}") - - return self.stats - - def generate_report(self, output_file: str = "data_analysis_report.json"): - """Generate a comprehensive analysis report""" - report = { - 'database_info': { - 'path': self.db_path, - 'analyzed_at': datetime.now().isoformat(), - 'total_tables': len(self.stats) - }, - 'summary': { - 'total_records': sum(stats.record_count for stats in self.stats.values()), - 'tables_with_issues': len([stats for stats in self.stats.values() if stats.data_quality_issues]), - 'total_issues': sum(len(stats.data_quality_issues) for stats in self.stats.values()) - }, - 'tables': {} - } - - for table_name, stats in self.stats.items(): - report['tables'][table_name] = { - 'record_count': stats.record_count, - 'columns': [col['name'] for col in stats.column_info], - 'foreign_keys': [fk['table'] for fk in stats.foreign_keys], - 'data_quality_issues': stats.data_quality_issues, - 'sample_records': stats.sample_records[:2] # Limit sample records in report - } - - with open(output_file, 'w', encoding='utf-8') as f: - json.dump(report, f, indent=2, ensure_ascii=False) - - print(f"Analysis report saved to: {output_file}") - return report - - def print_summary(self): - """Print a summary of the analysis""" - print("\n" + "="*80) - print("DATA ANALYSIS SUMMARY") - print("="*80) - - total_records = sum(stats.record_count for stats in self.stats.values()) - total_issues = sum(len(stats.data_quality_issues) for stats in self.stats.values()) - - print(f"Total tables analyzed: {len(self.stats)}") - print(f"Total records: {total_records:,}") - print(f"Total data quality issues: {total_issues}") - print() - - print("Table Statistics:") - print("-" * 60) - for table_name, stats in sorted(self.stats.items()): - print(f"{table_name:25} | {stats.record_count:8,} records | {len(stats.data_quality_issues):2} issues") - - print("\nTables with Issues:") - print("-" * 60) - for table_name, stats in self.stats.items(): - if stats.data_quality_issues: - print(f"\n{table_name}:") - for issue in stats.data_quality_issues[:3]: # Show first 3 issues - print(f" - {issue}") - if len(stats.data_quality_issues) > 3: - print(f" ... and {len(stats.data_quality_issues) - 3} more issues") - -def main(): - """Main function to run the data analysis""" - db_path = "tercul_data.db" - - if not Path(db_path).exists(): - print(f"Database file not found: {db_path}") - return - - print("Starting data analysis...") - analyzer = DataAnalyzer(db_path) - - # Analyze all tables - analyzer.analyze_all_tables() - - # Generate report - analyzer.generate_report() - - # Print summary - analyzer.print_summary() - -if __name__ == "__main__": - main() diff --git a/scripts/prepare_migration.py b/scripts/prepare_migration.py deleted file mode 100644 index 051e4b4..0000000 --- a/scripts/prepare_migration.py +++ /dev/null @@ -1,995 +0,0 @@ -#!/usr/bin/env python3 -""" -Data Migration Preparation Script for Tercul - -This script prepares data from the current SQLite database for migration -to the Go/GORM schema. It handles ID conversion, timestamp parsing, -content extraction, and relationship mapping. -""" - -import sqlite3 -import json -import re -import uuid -from datetime import datetime -from typing import Dict, List, Any, Optional, Tuple -from dataclasses import dataclass -from pathlib import Path -import yaml -from bs4 import BeautifulSoup -import re - -@dataclass -class MigrationMapping: - """Mapping information for migration""" - old_id: str - new_id: int - table_name: str - -class DataMigrationPreparer: - def __init__(self, source_db_path: str, output_dir: str = "migration_data"): - self.source_db_path = source_db_path - self.output_dir = Path(output_dir) - self.output_dir.mkdir(exist_ok=True) - - self.conn = sqlite3.connect(source_db_path) - self.conn.row_factory = sqlite3.Row - - # ID mapping storage - self.id_mappings: Dict[str, Dict[str, int]] = {} - self.next_ids: Dict[str, int] = {} - - # Initialize next IDs - self.next_ids = { - 'countries': 1, - 'authors': 1, - 'works': 1, - 'translations': 1, - 'books': 1, - 'users': 1, - 'tags': 1, - 'categories': 1 - } - - def __del__(self): - if hasattr(self, 'conn'): - self.conn.close() - - def get_next_id(self, table_name: str) -> int: - """Get the next available ID for a table""" - if table_name not in self.next_ids: - self.next_ids[table_name] = 1 - current_id = self.next_ids[table_name] - self.next_ids[table_name] += 1 - return current_id - - def parse_timestamp(self, timestamp_str: str) -> Optional[str]: - """Parse timestamp string to ISO format""" - if not timestamp_str: - return None - - # Common timestamp formats - formats = [ - '%Y-%m-%d %H:%M:%S', - '%Y-%m-%d %H:%M:%S.%f', - '%Y-%m-%d', - '%Y-%m-%dT%H:%M:%S', - '%Y-%m-%dT%H:%M:%S.%fZ' - ] - - for fmt in formats: - try: - dt = datetime.strptime(timestamp_str, fmt) - return dt.isoformat() - except ValueError: - continue - - return None - - def extract_yaml_content(self, content: str) -> Dict[str, Any]: - """Extract content from Ruby object format""" - if not content or not isinstance(content, str): - return {} - - # Check if it's Ruby object format - if '--- !ruby/hash:ActiveSupport::HashWithIndifferentAccess' in content: - try: - # Method 1: Try to extract text field directly using regex - text_match = re.search(r'text:\s*"([^"]*(?:\\"[^"]*)*)"', content, re.DOTALL) - if text_match: - text_content = text_match.group(1) - # Unescape the text content - text_content = text_content.replace('\\n', '\n').replace('\\"', '"').replace('\\\\', '\\') - return {'text': text_content} - - # Method 2: Try to parse as YAML after cleaning - # First, unescape the content - unescaped = content.replace('\\n', '\n').replace('\\"', '"').replace('\\\\', '\\') - - # Extract the YAML part - yaml_start = unescaped.find('---') - if yaml_start != -1: - yaml_content = unescaped[yaml_start:] - - # Clean up the Ruby-specific parts - yaml_content = yaml_content.replace('!ruby/hash:ActiveSupport::HashWithIndifferentAccess', '') - - # Parse YAML - data = yaml.safe_load(yaml_content) - return data or {} - - except Exception as e: - # Method 3: Fallback - try to extract any text content - try: - # Look for any quoted content - text_match = re.search(r'text:\s*"([^"]*(?:\\"[^"]*)*)"', content, re.DOTALL) - if text_match: - text_content = text_match.group(1) - # Basic unescaping - text_content = text_content.replace('\\n', '\n').replace('\\"', '"') - return {'text': text_content} - except Exception as e2: - print(f"Error extracting text manually: {e2}") - return {} - - # If not Ruby format, return as plain text - return {'text': content} - - def html_to_markdown(self, content: str) -> str: - """Convert HTML content to Markdown format""" - if not content or not isinstance(content, str): - return "" - - try: - soup = BeautifulSoup(content, 'html.parser') - - # Convert common HTML elements to Markdown - # Paragraphs - for p in soup.find_all('p'): - p.replace_with(f"\n\n{p.get_text()}\n\n") - - # Headers - for i in range(1, 7): - for h in soup.find_all(f'h{i}'): - h.replace_with(f"\n\n{'#' * i} {h.get_text()}\n\n") - - # Bold - for b in soup.find_all(['b', 'strong']): - b.replace_with(f"**{b.get_text()}**") - - # Italic - for i in soup.find_all(['i', 'em']): - i.replace_with(f"*{i.get_text()}*") - - # Lists - for ul in soup.find_all('ul'): - items = [] - for li in ul.find_all('li'): - items.append(f"- {li.get_text()}") - ul.replace_with(f"\n\n{chr(10).join(items)}\n\n") - - for ol in soup.find_all('ol'): - items = [] - for i, li in enumerate(ol.find_all('li'), 1): - items.append(f"{i}. {li.get_text()}") - ol.replace_with(f"\n\n{chr(10).join(items)}\n\n") - - # Blockquotes - for blockquote in soup.find_all('blockquote'): - lines = blockquote.get_text().split('\n') - quoted_lines = [f"> {line}" for line in lines if line.strip()] - blockquote.replace_with(f"\n\n{chr(10).join(quoted_lines)}\n\n") - - # Links - for a in soup.find_all('a'): - href = a.get('href', '') - text = a.get_text() - a.replace_with(f"[{text}]({href})") - - # Get the final text and clean up - result = soup.get_text() - - # Clean up excessive whitespace - result = re.sub(r'\n\s*\n\s*\n', '\n\n', result) - result = result.strip() - - return result - - except Exception as e: - print(f"Error converting HTML to Markdown: {e}") - return content - - def migrate_countries(self) -> List[Dict[str, Any]]: - """Migrate countries data""" - print("Migrating countries...") - - cursor = self.conn.cursor() - cursor.execute(""" - SELECT c.id, c.created_at, c.updated_at, - ct.name, ct.language_code - FROM countries c - LEFT JOIN country_translations ct ON c.id = ct.country_id - ORDER BY c.id - """) - - countries = {} - for row in cursor.fetchall(): - country_id = row['id'] - - if country_id not in countries: - new_id = self.get_next_id('countries') - self.id_mappings.setdefault('countries', {})[country_id] = new_id - - countries[country_id] = { - 'id': new_id, - 'name': '', - 'code': '', # Will need to be generated or mapped - 'phone_code': '', - 'currency': '', - 'continent': '', - 'language': 'multi', - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - # Add translation data - if row['name'] and row['language_code']: - if row['language_code'] == 'en': - countries[country_id]['name'] = row['name'] - # Generate country code from name - countries[country_id]['code'] = row['name'][:2].upper() - - return list(countries.values()) - - def migrate_authors(self) -> List[Dict[str, Any]]: - """Migrate authors data""" - print("Migrating authors...") - - cursor = self.conn.cursor() - cursor.execute(""" - SELECT a.id, a.date_of_birth, a.date_of_death, a.created_at, a.updated_at, - a.is_top, a.date_of_death_precision, a.date_of_birth_precision, - a.is_draft, a.custom_date_of_birth, a.custom_date_of_death, a.slug, - at.first_name, at.last_name, at.full_name, at.place_of_birth, - at.place_of_death, at.pen_names, at.language_code, - atr.body as biography - FROM authors a - LEFT JOIN author_translations at ON a.id = at.author_id - LEFT JOIN action_text_rich_texts atr ON at.id = atr.record_id AND atr.name = 'biography' - ORDER BY a.id - """) - - authors = {} - for row in cursor.fetchall(): - author_id = row['id'] - - if author_id not in authors: - new_id = self.get_next_id('authors') - self.id_mappings.setdefault('authors', {})[author_id] = new_id - - authors[author_id] = { - 'id': new_id, - 'name': '', - 'biography': '', - 'birth_date': None, - 'death_date': None, - 'language': 'multi', - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - # Add translation data - if row['language_code'] == 'en': - if row['full_name']: - authors[author_id]['name'] = row['full_name'] - elif row['first_name'] and row['last_name']: - authors[author_id]['name'] = f"{row['first_name']} {row['last_name']}" - - # Parse dates - if row['date_of_birth']: - authors[author_id]['birth_date'] = self.parse_timestamp(row['date_of_birth']) - if row['date_of_death']: - authors[author_id]['death_date'] = self.parse_timestamp(row['date_of_death']) - - # Add biography - if row['biography']: - content = self.extract_yaml_content(row['biography']) - if 'text' in content: - authors[author_id]['biography'] = self.html_to_markdown(content['text']) - - return list(authors.values()) - - def migrate_works(self) -> List[Dict[str, Any]]: - """Migrate works data""" - print("Migrating works...") - - cursor = self.conn.cursor() - cursor.execute(""" - SELECT w.id, w.author_id, w.date_created, w.age_restrictions, - w.literature_type, w.created_at, w.updated_at, w.is_top, - w.date_created_precision, w.is_draft, w.genres, w.slug - FROM works w - ORDER BY w.id - """) - - works = [] - for row in cursor.fetchall(): - new_id = self.get_next_id('works') - self.id_mappings.setdefault('works', {})[row['id']] = new_id - - # Map author_id - author_id = None - if row['author_id'] and row['author_id'] in self.id_mappings.get('authors', {}): - author_id = self.id_mappings['authors'][row['author_id']] - - work = { - 'id': new_id, - 'title': '', # Will be filled from translations - 'content': '', # Will be filled from translations - 'description': '', - 'author_id': author_id, - 'published_at': self.parse_timestamp(row['date_created']), - 'status': 'draft' if row['is_draft'] else 'published', - 'language': 'multi', - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - works.append(work) - - return works - - def migrate_translations(self) -> List[Dict[str, Any]]: - """Migrate all translations data (polymorphic approach)""" - print("Migrating translations...") - - translations = [] - - # Migrate work translations - cursor = self.conn.cursor() - cursor.execute(""" - SELECT wt.id, wt.work_id, wt.title, wt.audio_url, wt.translator, - wt.date_translated, wt.is_original_language, wt.created_at, - wt.updated_at, wt.language_code, wt.body - FROM work_translations wt - ORDER BY wt.work_id, wt.language_code - """) - - for row in cursor.fetchall(): - new_id = self.get_next_id('translations') - self.id_mappings.setdefault('translations', {})[row['id']] = new_id - - # Map work_id - translatable_id = None - if row['work_id'] and row['work_id'] in self.id_mappings.get('works', {}): - translatable_id = self.id_mappings['works'][row['work_id']] - - # Extract content - content = "" - if row['body']: - content_data = self.extract_yaml_content(row['body']) - if 'text' in content_data: - content = self.html_to_markdown(content_data['text']) - - translation = { - 'id': new_id, - 'title': row['title'] or '', - 'content': content, - 'description': '', - 'language': row['language_code'] or 'en', - 'status': 'published' if row['is_original_language'] else 'draft', - 'published_at': self.parse_timestamp(row['date_translated']), - 'translatable_id': translatable_id, - 'translatable_type': 'Work', - 'translator_id': None, # Will need user mapping - 'is_original_language': bool(row['is_original_language']), - 'audio_url': row['audio_url'] or '', - 'date_translated': self.parse_timestamp(row['date_translated']), - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - translations.append(translation) - - # Migrate author translations - cursor.execute(""" - SELECT at.id, at.author_id, at.first_name, at.last_name, at.full_name, - at.place_of_birth, at.place_of_death, at.pen_names, at.language_code, - at.created_at, at.updated_at, - atr.body as biography - FROM author_translations at - LEFT JOIN action_text_rich_texts atr ON at.id = atr.record_id AND atr.name = 'biography' - ORDER BY at.author_id, at.language_code - """) - - for row in cursor.fetchall(): - new_id = self.get_next_id('translations') - self.id_mappings.setdefault('translations', {})[row['id']] = new_id - - # Map author_id - translatable_id = None - if row['author_id'] and row['author_id'] in self.id_mappings.get('authors', {}): - translatable_id = self.id_mappings['authors'][row['author_id']] - - # Extract biography - content = "" - if row['biography']: - content_data = self.extract_yaml_content(row['biography']) - if 'text' in content_data: - content = self.html_to_markdown(content_data['text']) - - # Create title from name components - title = "" - if row['full_name']: - title = row['full_name'] - elif row['first_name'] and row['last_name']: - title = f"{row['first_name']} {row['last_name']}" - - translation = { - 'id': new_id, - 'title': title, - 'content': content, - 'description': '', - 'language': row['language_code'] or 'en', - 'status': 'published', - 'published_at': None, - 'translatable_id': translatable_id, - 'translatable_type': 'Author', - 'translator_id': None, - 'is_original_language': False, - 'audio_url': '', - 'date_translated': None, - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - translations.append(translation) - - # Migrate country translations - cursor.execute(""" - SELECT ct.id, ct.country_id, ct.name, ct.language_code, - ct.created_at, ct.updated_at - FROM country_translations ct - ORDER BY ct.country_id, ct.language_code - """) - - for row in cursor.fetchall(): - new_id = self.get_next_id('translations') - self.id_mappings.setdefault('translations', {})[row['id']] = new_id - - # Map country_id - translatable_id = None - if row['country_id'] and row['country_id'] in self.id_mappings.get('countries', {}): - translatable_id = self.id_mappings['countries'][row['country_id']] - - translation = { - 'id': new_id, - 'title': row['name'] or '', - 'content': '', - 'description': '', - 'language': row['language_code'] or 'en', - 'status': 'published', - 'published_at': None, - 'translatable_id': translatable_id, - 'translatable_type': 'Country', - 'translator_id': None, - 'is_original_language': False, - 'audio_url': '', - 'date_translated': None, - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - translations.append(translation) - - return translations - - def migrate_books(self) -> List[Dict[str, Any]]: - """Migrate books data""" - print("Migrating books...") - - cursor = self.conn.cursor() - cursor.execute(""" - SELECT b.id, b.created_at, b.updated_at, b.genre, b.types, - b.is_draft, b.genres, - bt.title, bt.annotation, bt.introduction, bt.isbn, bt.publisher, - bt.language_code - FROM books b - LEFT JOIN book_translations bt ON b.id = bt.book_id - ORDER BY b.id - """) - - books = {} - for row in cursor.fetchall(): - book_id = row['id'] - - if book_id not in books: - new_id = self.get_next_id('books') - self.id_mappings.setdefault('books', {})[book_id] = new_id - - books[book_id] = { - 'id': new_id, - 'title': '', - 'description': '', - 'isbn': '', - 'published_at': None, - 'language': 'multi', - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - # Add translation data - if row['language_code'] == 'en': - if row['title']: - books[book_id]['title'] = row['title'] - if row['annotation']: - books[book_id]['description'] = row['annotation'] - elif row['introduction']: - books[book_id]['description'] = row['introduction'] - if row['isbn']: - books[book_id]['isbn'] = row['isbn'] - - return list(books.values()) - - def migrate_users(self) -> List[Dict[str, Any]]: - """Migrate users data""" - print("Migrating users...") - - cursor = self.conn.cursor() - cursor.execute("SELECT * FROM users") - - users = [] - for row in cursor.fetchall(): - new_id = self.get_next_id('users') - self.id_mappings.setdefault('users', {})[row['id']] = new_id - - user = { - 'id': new_id, - 'username': row['name'] or f"user_{new_id}", - 'email': row['email'] or f"user_{new_id}@example.com", - 'password': row['password'] or '', # Will need to be rehashed - 'first_name': '', - 'last_name': '', - 'display_name': row['name'] or '', - 'bio': '', - 'avatar_url': '', - 'role': 'reader', - 'verified': False, - 'active': True, - 'created_at': self.parse_timestamp(row['created_at']), - 'updated_at': self.parse_timestamp(row['updated_at']) - } - - users.append(user) - - return users - - def create_junction_tables(self) -> Dict[str, List[Dict[str, Any]]]: - """Create junction table data""" - print("Creating junction tables...") - - junction_data = { - 'book_works': [], - 'author_countries': [] - } - - # Book-Works relationships - cursor = self.conn.cursor() - cursor.execute("SELECT book_id, work_id, `order` FROM book_works") - - for row in cursor.fetchall(): - book_id = self.id_mappings.get('books', {}).get(row['book_id']) - work_id = self.id_mappings.get('works', {}).get(row['work_id']) - - if book_id and work_id: - junction_data['book_works'].append({ - 'book_id': book_id, - 'work_id': work_id, - 'order': int(row['order']) if row['order'] else 0 - }) - - # Author-Countries relationships - cursor.execute("SELECT author_id, country_id FROM author_countries") - - for row in cursor.fetchall(): - author_id = self.id_mappings.get('authors', {}).get(row['author_id']) - country_id = self.id_mappings.get('countries', {}).get(row['country_id']) - - if author_id and country_id: - junction_data['author_countries'].append({ - 'author_id': author_id, - 'country_id': country_id - }) - - return junction_data - - def create_sqlite_database(self): - """Create SQLite database with Go/GORM schema and import data""" - print("Creating SQLite database...") - - # Migrate core entities - countries = self.migrate_countries() - authors = self.migrate_authors() - works = self.migrate_works() - translations = self.migrate_translations() - books = self.migrate_books() - users = self.migrate_users() - - # Create junction tables - junction_data = self.create_junction_tables() - - # Create new SQLite database - db_path = self.output_dir / "tercul_go.db" - new_conn = sqlite3.connect(db_path) - new_conn.row_factory = sqlite3.Row - - # Create tables with proper schema - self.create_tables(new_conn) - - # Import data - self.import_data(new_conn, countries, authors, works, translations, books, users, junction_data) - - new_conn.close() - print(f"SQLite database created: {db_path}") - - return { - 'countries': countries, - 'authors': authors, - 'works': works, - 'translations': translations, - 'books': books, - 'users': users, - 'junction_tables': junction_data, - 'id_mappings': self.id_mappings, - 'metadata': { - 'total_records': len(countries) + len(authors) + len(works) + len(translations) + len(books) + len(users), - 'migrated_at': datetime.now().isoformat(), - 'source_database': self.source_db_path, - 'target_database': str(db_path) - } - } - - def create_tables(self, conn): - """Create tables with Go/GORM compatible schema""" - cursor = conn.cursor() - - # Create countries table - cursor.execute(""" - CREATE TABLE countries ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - code TEXT, - phone_code TEXT, - currency TEXT, - continent TEXT, - language TEXT DEFAULT 'multi', - created_at TEXT, - updated_at TEXT - ) - """) - - # Create authors table - cursor.execute(""" - CREATE TABLE authors ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - status TEXT DEFAULT 'active', - birth_date TEXT, - death_date TEXT, - language TEXT DEFAULT 'multi', - created_at TEXT, - updated_at TEXT - ) - """) - - # Create works table - cursor.execute(""" - CREATE TABLE works ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - description TEXT, - type TEXT DEFAULT 'other', - status TEXT DEFAULT 'draft', - published_at TEXT, - language TEXT DEFAULT 'multi', - created_at TEXT, - updated_at TEXT - ) - """) - - # Create translations table (polymorphic) - cursor.execute(""" - CREATE TABLE translations ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - content TEXT, - description TEXT, - language TEXT NOT NULL, - status TEXT DEFAULT 'draft', - published_at TEXT, - translatable_id INTEGER NOT NULL, - translatable_type TEXT NOT NULL, - translator_id INTEGER, - is_original_language BOOLEAN DEFAULT FALSE, - audio_url TEXT, - date_translated TEXT, - created_at TEXT, - updated_at TEXT, - FOREIGN KEY (translator_id) REFERENCES users (id) - ) - """) - - # Create books table - cursor.execute(""" - CREATE TABLE books ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - description TEXT, - isbn TEXT, - format TEXT DEFAULT 'paperback', - status TEXT DEFAULT 'draft', - published_at TEXT, - language TEXT DEFAULT 'multi', - created_at TEXT, - updated_at TEXT - ) - """) - - # Create users table - cursor.execute(""" - CREATE TABLE users ( - id INTEGER PRIMARY KEY, - username TEXT NOT NULL UNIQUE, - email TEXT NOT NULL UNIQUE, - password TEXT NOT NULL, - first_name TEXT, - last_name TEXT, - display_name TEXT, - bio TEXT, - avatar_url TEXT, - role TEXT DEFAULT 'reader', - verified BOOLEAN DEFAULT FALSE, - active BOOLEAN DEFAULT TRUE, - created_at TEXT, - updated_at TEXT - ) - """) - - # Create junction tables - cursor.execute(""" - CREATE TABLE book_works ( - id INTEGER PRIMARY KEY, - book_id INTEGER NOT NULL, - work_id INTEGER NOT NULL, - "order" INTEGER DEFAULT 0, - created_at TEXT, - updated_at TEXT, - FOREIGN KEY (book_id) REFERENCES books (id), - FOREIGN KEY (work_id) REFERENCES works (id) - ) - """) - - cursor.execute(""" - CREATE TABLE author_countries ( - id INTEGER PRIMARY KEY, - author_id INTEGER NOT NULL, - country_id INTEGER NOT NULL, - created_at TEXT, - updated_at TEXT, - FOREIGN KEY (author_id) REFERENCES authors (id), - FOREIGN KEY (country_id) REFERENCES countries (id) - ) - """) - - # Create indexes - cursor.execute("CREATE INDEX idx_translations_translatable ON translations (translatable_id, translatable_type)") - cursor.execute("CREATE INDEX idx_translations_language ON translations (language)") - cursor.execute("CREATE INDEX idx_works_author ON works (author_id)") - cursor.execute("CREATE INDEX idx_book_works_book ON book_works (book_id)") - cursor.execute("CREATE INDEX idx_book_works_work ON book_works (work_id)") - cursor.execute("CREATE INDEX idx_author_countries_author ON author_countries (author_id)") - cursor.execute("CREATE INDEX idx_author_countries_country ON author_countries (country_id)") - - conn.commit() - print("Tables created successfully") - - def import_data(self, conn, countries, authors, works, translations, books, users, junction_data): - """Import data into the new database""" - cursor = conn.cursor() - - # Import countries - print("Importing countries...") - for country in countries: - cursor.execute(""" - INSERT INTO countries (id, name, code, phone_code, currency, continent, language, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - country['id'], country['name'], country['code'], country['phone_code'], - country['currency'], country['continent'], country['language'], - country['created_at'], country['updated_at'] - )) - - # Import authors - print("Importing authors...") - for author in authors: - cursor.execute(""" - INSERT INTO authors (id, name, biography, birth_date, death_date, language, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - author['id'], author['name'], author['biography'], author['birth_date'], - author['death_date'], author['language'], author['created_at'], author['updated_at'] - )) - - # Import works - print("Importing works...") - for work in works: - cursor.execute(""" - INSERT INTO works (id, title, content, description, author_id, published_at, status, language, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - work['id'], work['title'], work['content'], work['description'], - work['author_id'], work['published_at'], work['status'], work['language'], - work['created_at'], work['updated_at'] - )) - - # Import translations - print("Importing translations...") - for translation in translations: - cursor.execute(""" - INSERT INTO translations (id, title, content, description, language, status, published_at, - translatable_id, translatable_type, translator_id, is_original_language, - audio_url, date_translated, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - translation['id'], translation['title'], translation['content'], translation['description'], - translation['language'], translation['status'], translation['published_at'], - translation['translatable_id'], translation['translatable_type'], translation['translator_id'], - translation['is_original_language'], translation['audio_url'], translation['date_translated'], - translation['created_at'], translation['updated_at'] - )) - - # Import books - print("Importing books...") - for book in books: - cursor.execute(""" - INSERT INTO books (id, title, description, isbn, published_at, language, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - """, ( - book['id'], book['title'], book['description'], book['isbn'], - book['published_at'], book['language'], book['created_at'], book['updated_at'] - )) - - # Import users - print("Importing users...") - for user in users: - cursor.execute(""" - INSERT INTO users (id, username, email, password, first_name, last_name, display_name, bio, avatar_url, role, verified, active, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - """, ( - user['id'], user['username'], user['email'], user['password'], - user['first_name'], user['last_name'], user['display_name'], user['bio'], - user['avatar_url'], user['role'], user['verified'], user['active'], - user['created_at'], user['updated_at'] - )) - - # Import junction tables - print("Importing junction tables...") - for book_work in junction_data['book_works']: - cursor.execute(""" - INSERT INTO book_works (book_id, work_id, "order") - VALUES (?, ?, ?) - """, (book_work['book_id'], book_work['work_id'], book_work['order'])) - - for author_country in junction_data['author_countries']: - cursor.execute(""" - INSERT INTO author_countries (author_id, country_id) - VALUES (?, ?) - """, (author_country['author_id'], author_country['country_id'])) - - conn.commit() - print("Data import completed") - - def save_migration_data(self): - """Save all migration data to JSON files""" - print("Saving migration data...") - - # Migrate core entities - countries = self.migrate_countries() - authors = self.migrate_authors() - works = self.migrate_works() - translations = self.migrate_translations() - books = self.migrate_books() - users = self.migrate_users() - - # Create junction tables - junction_data = self.create_junction_tables() - - # Save data to files - migration_data = { - 'countries': countries, - 'authors': authors, - 'works': works, - 'translations': translations, - 'books': books, - 'users': users, - 'junction_tables': junction_data, - 'id_mappings': self.id_mappings, - 'metadata': { - 'total_records': len(countries) + len(authors) + len(works) + len(translations) + len(books) + len(users), - 'migrated_at': datetime.now().isoformat(), - 'source_database': self.source_db_path - } - } - - # Save main migration data - with open(self.output_dir / 'migration_data.json', 'w', encoding='utf-8') as f: - json.dump(migration_data, f, indent=2, ensure_ascii=False) - - # Save ID mappings separately for reference - with open(self.output_dir / 'id_mappings.json', 'w', encoding='utf-8') as f: - json.dump(self.id_mappings, f, indent=2, ensure_ascii=False) - - # Save individual entity files - for entity_name, data in [ - ('countries', countries), - ('authors', authors), - ('works', works), - ('translations', translations), - ('books', books), - ('users', users) - ]: - with open(self.output_dir / f'{entity_name}.json', 'w', encoding='utf-8') as f: - json.dump(data, f, indent=2, ensure_ascii=False) - - print(f"Migration data saved to: {self.output_dir}") - return migration_data - - def generate_migration_summary(self) -> Dict[str, Any]: - """Generate a summary of the migration""" - summary = { - 'source_database': self.source_db_path, - 'migrated_at': datetime.now().isoformat(), - 'record_counts': { - 'countries': len(self.id_mappings.get('countries', {})), - 'authors': len(self.id_mappings.get('authors', {})), - 'works': len(self.id_mappings.get('works', {})), - 'translations': len(self.id_mappings.get('translations', {})), - 'books': len(self.id_mappings.get('books', {})), - 'users': len(self.id_mappings.get('users', {})) - }, - 'total_records': sum(len(mapping) for mapping in self.id_mappings.values()), - 'next_ids': self.next_ids - } - - with open(self.output_dir / 'migration_summary.json', 'w', encoding='utf-8') as f: - json.dump(summary, f, indent=2, ensure_ascii=False) - - return summary - -def main(): - """Main function to run the data migration preparation""" - source_db = "tercul_data.db" - - if not Path(source_db).exists(): - print(f"Source database not found: {source_db}") - return - - print("Starting data migration and database creation...") - preparer = DataMigrationPreparer(source_db) - - # Create SQLite database with migrated data - migration_data = preparer.create_sqlite_database() - - # Generate summary - summary = preparer.generate_migration_summary() - - print("\nMigration and Database Creation Summary:") - print("=" * 50) - for entity, count in summary['record_counts'].items(): - print(f"{entity:15}: {count:6,} records") - print(f"{'Total':15}: {summary['total_records']:6,} records") - print(f"\nSQLite database created: {preparer.output_dir}/tercul_go.db") - print(f"Migration data saved to: {preparer.output_dir}") - -if __name__ == "__main__": - main() diff --git a/scripts/test_markdown_conversion.py b/scripts/test_markdown_conversion.py deleted file mode 100644 index ef7f3c8..0000000 --- a/scripts/test_markdown_conversion.py +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env python3 -""" -Test script to demonstrate HTML to Markdown conversion -""" - -import re -from bs4 import BeautifulSoup - -def html_to_markdown(content: str) -> str: - """Convert HTML content to Markdown format""" - if not content or not isinstance(content, str): - return "" - - try: - soup = BeautifulSoup(content, 'html.parser') - - # Convert common HTML elements to Markdown - # Paragraphs - for p in soup.find_all('p'): - p.replace_with(f"\n\n{p.get_text()}\n\n") - - # Headers - for i in range(1, 7): - for h in soup.find_all(f'h{i}'): - h.replace_with(f"\n\n{'#' * i} {h.get_text()}\n\n") - - # Bold - for b in soup.find_all(['b', 'strong']): - b.replace_with(f"**{b.get_text()}**") - - # Italic - for i in soup.find_all(['i', 'em']): - i.replace_with(f"*{i.get_text()}*") - - # Lists - for ul in soup.find_all('ul'): - items = [] - for li in ul.find_all('li'): - items.append(f"- {li.get_text()}") - ul.replace_with(f"\n\n{chr(10).join(items)}\n\n") - - for ol in soup.find_all('ol'): - items = [] - for i, li in enumerate(ol.find_all('li'), 1): - items.append(f"{i}. {li.get_text()}") - ol.replace_with(f"\n\n{chr(10).join(items)}\n\n") - - # Blockquotes - for blockquote in soup.find_all('blockquote'): - lines = blockquote.get_text().split('\n') - quoted_lines = [f"> {line}" for line in lines if line.strip()] - blockquote.replace_with(f"\n\n{chr(10).join(quoted_lines)}\n\n") - - # Links - for a in soup.find_all('a'): - href = a.get('href', '') - text = a.get_text() - a.replace_with(f"[{text}]({href})") - - # Get the final text and clean up - result = soup.get_text() - - # Clean up excessive whitespace - result = re.sub(r'\n\s*\n\s*\n', '\n\n', result) - result = result.strip() - - return result - - except Exception as e: - print(f"Error converting HTML to Markdown: {e}") - return content - -def test_conversion(): - """Test the HTML to Markdown conversion with sample data""" - - # Sample HTML content from the database - sample_html = """ -

Жил в одном ауле старик с двумя сыновьями. Пришла старику пора помирать. - Позвал он сыновей и говорит:

— Мои дорогие дети, я оставляю вам наследство. - Но не наследством вы будете богаты. Дороже денег, дороже добра три совета. Будете - их помнить — проживёте в достатке всю жизнь. Вот мои советы, запоминайте. Первыми - никому не кланяйтесь — пусть другие вам кланяются. Всякую еду ешьте с мёдом. Спите - всегда на пуховиках.

Старик умер.

Сыновья забыли о его советах и давай - жить в своё удовольствие — пить да гулять, много есть и долго спать.

- """ - - print("Original HTML:") - print("=" * 50) - print(sample_html) - print() - - print("Converted to Markdown:") - print("=" * 50) - markdown = html_to_markdown(sample_html) - print(markdown) - print() - - print("Benefits of Markdown format:") - print("- Preserves paragraph structure") - print("- Maintains dialogue formatting") - print("- Easy to read and edit") - print("- Can be rendered to HTML when needed") - print("- Smaller file size than HTML") - print("- Better for version control") - -if __name__ == "__main__": - test_conversion() diff --git a/services/auth_service.go b/services/auth_service.go index 9f27e1e..5b3d09e 100644 --- a/services/auth_service.go +++ b/services/auth_service.go @@ -5,13 +5,13 @@ import ( "errors" "fmt" "strings" + models2 "tercul/internal/models" + "tercul/internal/repositories" "time" "github.com/asaskevich/govalidator" - "tercul/auth" - "tercul/logger" - "tercul/models" - "tercul/repositories" + "tercul/internal/platform/auth" + "tercul/internal/platform/log" ) var ( @@ -26,8 +26,8 @@ var ( type AuthService interface { Login(ctx context.Context, input LoginInput) (*AuthResponse, error) Register(ctx context.Context, input RegisterInput) (*AuthResponse, error) - GetUserFromContext(ctx context.Context) (*models.User, error) - ValidateToken(ctx context.Context, tokenString string) (*models.User, error) + GetUserFromContext(ctx context.Context) (*models2.User, error) + ValidateToken(ctx context.Context, tokenString string) (*models2.User, error) } // authService handles authentication operations @@ -61,9 +61,9 @@ type RegisterInput struct { // AuthResponse represents authentication response type AuthResponse struct { - Token string `json:"token"` - User *models.User `json:"user"` - ExpiresAt time.Time `json:"expires_at"` + Token string `json:"token"` + User *models2.User `json:"user"` + ExpiresAt time.Time `json:"expires_at"` } // Login authenticates a user and returns a JWT token @@ -75,9 +75,9 @@ func (s *authService) Login(ctx context.Context, input LoginInput) (*AuthRespons // Validate input if err := s.validateLoginInput(input); err != nil { - logger.LogWarn("Login failed - invalid input", - logger.F("email", input.Email), - logger.F("error", err)) + log.LogWarn("Login failed - invalid input", + log.F("email", input.Email), + log.F("error", err)) return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) } @@ -87,34 +87,34 @@ func (s *authService) Login(ctx context.Context, input LoginInput) (*AuthRespons // Find user by email user, err := s.userRepo.FindByEmail(ctx, email) if err != nil { - logger.LogWarn("Login failed - user not found", - logger.F("email", email)) + log.LogWarn("Login failed - user not found", + log.F("email", email)) return nil, ErrInvalidCredentials } // Check if user is active if !user.Active { - logger.LogWarn("Login failed - user inactive", - logger.F("user_id", user.ID), - logger.F("email", email)) + log.LogWarn("Login failed - user inactive", + log.F("user_id", user.ID), + log.F("email", email)) return nil, ErrInvalidCredentials } // Verify password if !user.CheckPassword(input.Password) { - logger.LogWarn("Login failed - invalid password", - logger.F("user_id", user.ID), - logger.F("email", email)) + log.LogWarn("Login failed - invalid password", + log.F("user_id", user.ID), + log.F("email", email)) return nil, ErrInvalidCredentials } // Generate JWT token token, err := s.jwtManager.GenerateToken(user) if err != nil { - logger.LogError("Failed to generate JWT token", - logger.F("user_id", user.ID), - logger.F("email", email), - logger.F("error", err)) + log.LogError("Failed to generate JWT token", + log.F("user_id", user.ID), + log.F("email", email), + log.F("error", err)) return nil, fmt.Errorf("failed to generate token: %w", err) } @@ -122,16 +122,16 @@ func (s *authService) Login(ctx context.Context, input LoginInput) (*AuthRespons now := time.Now() user.LastLoginAt = &now if err := s.userRepo.Update(ctx, user); err != nil { - logger.LogWarn("Failed to update last login time", - logger.F("user_id", user.ID), - logger.F("error", err)) + log.LogWarn("Failed to update last login time", + log.F("user_id", user.ID), + log.F("error", err)) // Don't fail the login if we can't update the timestamp } - logger.LogInfo("User logged in successfully", - logger.F("user_id", user.ID), - logger.F("email", email), - logger.F("role", user.Role)) + log.LogInfo("User logged in successfully", + log.F("user_id", user.ID), + log.F("email", email), + log.F("role", user.Role)) return &AuthResponse{ Token: token, @@ -149,10 +149,10 @@ func (s *authService) Register(ctx context.Context, input RegisterInput) (*AuthR // Validate input if err := s.validateRegisterInput(input); err != nil { - logger.LogWarn("Registration failed - invalid input", - logger.F("email", input.Email), - logger.F("username", input.Username), - logger.F("error", err)) + log.LogWarn("Registration failed - invalid input", + log.F("email", input.Email), + log.F("username", input.Username), + log.F("error", err)) return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err) } @@ -165,56 +165,56 @@ func (s *authService) Register(ctx context.Context, input RegisterInput) (*AuthR // Check if user already exists by email existingUser, err := s.userRepo.FindByEmail(ctx, email) if err == nil && existingUser != nil { - logger.LogWarn("Registration failed - email already exists", - logger.F("email", email)) + log.LogWarn("Registration failed - email already exists", + log.F("email", email)) return nil, ErrUserAlreadyExists } // Check if user already exists by username existingUser, err = s.userRepo.FindByUsername(ctx, username) if err == nil && existingUser != nil { - logger.LogWarn("Registration failed - username already exists", - logger.F("username", username)) + log.LogWarn("Registration failed - username already exists", + log.F("username", username)) return nil, ErrUserAlreadyExists } // Create new user - user := &models.User{ + user := &models2.User{ Username: username, Email: email, Password: input.Password, // Will be hashed by BeforeSave hook FirstName: firstName, LastName: lastName, DisplayName: firstName + " " + lastName, - Role: models.UserRoleReader, // Default role + Role: models2.UserRoleReader, // Default role Active: true, Verified: false, // Email verification required } // Save user to database if err := s.userRepo.Create(ctx, user); err != nil { - logger.LogError("Failed to create user", - logger.F("email", email), - logger.F("username", username), - logger.F("error", err)) + log.LogError("Failed to create user", + log.F("email", email), + log.F("username", username), + log.F("error", err)) return nil, fmt.Errorf("failed to create user: %w", err) } // Generate JWT token token, err := s.jwtManager.GenerateToken(user) if err != nil { - logger.LogError("Failed to generate JWT token for new user", - logger.F("user_id", user.ID), - logger.F("email", email), - logger.F("error", err)) + log.LogError("Failed to generate JWT token for new user", + log.F("user_id", user.ID), + log.F("email", email), + log.F("error", err)) return nil, fmt.Errorf("failed to generate token: %w", err) } - logger.LogInfo("User registered successfully", - logger.F("user_id", user.ID), - logger.F("email", email), - logger.F("username", username), - logger.F("role", user.Role)) + log.LogInfo("User registered successfully", + log.F("user_id", user.ID), + log.F("email", email), + log.F("username", username), + log.F("role", user.Role)) return &AuthResponse{ Token: token, @@ -224,7 +224,7 @@ func (s *authService) Register(ctx context.Context, input RegisterInput) (*AuthR } // GetUserFromContext extracts user from context -func (s *authService) GetUserFromContext(ctx context.Context) (*models.User, error) { +func (s *authService) GetUserFromContext(ctx context.Context) (*models2.User, error) { // Validate context if ctx == nil { return nil, ErrContextRequired @@ -232,23 +232,23 @@ func (s *authService) GetUserFromContext(ctx context.Context) (*models.User, err claims, err := auth.RequireAuth(ctx) if err != nil { - logger.LogWarn("Failed to get user from context - authentication required", - logger.F("error", err)) + log.LogWarn("Failed to get user from context - authentication required", + log.F("error", err)) return nil, err } user, err := s.userRepo.GetByID(ctx, claims.UserID) if err != nil { - logger.LogWarn("Failed to get user from context - user not found", - logger.F("user_id", claims.UserID), - logger.F("error", err)) + log.LogWarn("Failed to get user from context - user not found", + log.F("user_id", claims.UserID), + log.F("error", err)) return nil, ErrUserNotFound } // Check if user is still active if !user.Active { - logger.LogWarn("Failed to get user from context - user inactive", - logger.F("user_id", user.ID)) + log.LogWarn("Failed to get user from context - user inactive", + log.F("user_id", user.ID)) return nil, ErrInvalidCredentials } @@ -256,7 +256,7 @@ func (s *authService) GetUserFromContext(ctx context.Context) (*models.User, err } // ValidateToken validates a JWT token and returns the user -func (s *authService) ValidateToken(ctx context.Context, tokenString string) (*models.User, error) { +func (s *authService) ValidateToken(ctx context.Context, tokenString string) (*models2.User, error) { // Validate context if ctx == nil { return nil, ErrContextRequired @@ -264,34 +264,34 @@ func (s *authService) ValidateToken(ctx context.Context, tokenString string) (*m // Validate token string if tokenString == "" { - logger.LogWarn("Token validation failed - empty token") + log.LogWarn("Token validation failed - empty token") return nil, auth.ErrMissingToken } claims, err := s.jwtManager.ValidateToken(tokenString) if err != nil { - logger.LogWarn("Token validation failed - invalid token", - logger.F("error", err)) + log.LogWarn("Token validation failed - invalid token", + log.F("error", err)) return nil, err } user, err := s.userRepo.GetByID(ctx, claims.UserID) if err != nil { - logger.LogWarn("Token validation failed - user not found", - logger.F("user_id", claims.UserID), - logger.F("error", err)) + log.LogWarn("Token validation failed - user not found", + log.F("user_id", claims.UserID), + log.F("error", err)) return nil, ErrUserNotFound } if !user.Active { - logger.LogWarn("Token validation failed - user inactive", - logger.F("user_id", user.ID)) + log.LogWarn("Token validation failed - user inactive", + log.F("user_id", user.ID)) return nil, ErrInvalidCredentials } - logger.LogInfo("Token validated successfully", - logger.F("user_id", user.ID), - logger.F("role", user.Role)) + log.LogInfo("Token validated successfully", + log.F("user_id", user.ID), + log.F("role", user.Role)) return user, nil } @@ -304,18 +304,18 @@ func (s *authService) validateLoginInput(input LoginInput) error { if input.Password == "" { return errors.New("password is required") } - + // Sanitize and validate email email := strings.TrimSpace(input.Email) if !govalidator.IsEmail(email) { return errors.New("invalid email format") } - + // Validate password length if len(input.Password) < 6 { return errors.New("password must be at least 6 characters") } - + return nil } @@ -336,7 +336,7 @@ func (s *authService) validateRegisterInput(input RegisterInput) error { if input.LastName == "" { return errors.New("last name is required") } - + // Sanitize and validate username username := strings.TrimSpace(input.Username) if len(username) < 3 || len(username) > 50 { @@ -345,18 +345,18 @@ func (s *authService) validateRegisterInput(input RegisterInput) error { if !govalidator.Matches(username, `^[a-zA-Z0-9_-]+$`) { return errors.New("username can only contain letters, numbers, underscores, and hyphens") } - + // Sanitize and validate email email := strings.TrimSpace(input.Email) if !govalidator.IsEmail(email) { return errors.New("invalid email format") } - + // Validate password strength if len(input.Password) < 6 { return errors.New("password must be at least 6 characters") } - + // Sanitize and validate names firstName := strings.TrimSpace(input.FirstName) lastName := strings.TrimSpace(input.LastName) @@ -366,6 +366,6 @@ func (s *authService) validateRegisterInput(input RegisterInput) error { if len(lastName) < 1 || len(lastName) > 50 { return errors.New("last name must be between 1 and 50 characters") } - + return nil } diff --git a/services/auth_service_test.go b/services/auth_service_test.go index 3039959..7e531aa 100644 --- a/services/auth_service_test.go +++ b/services/auth_service_test.go @@ -2,12 +2,12 @@ package services import ( "context" + models2 "tercul/internal/models" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" - "tercul/models" ) // AuthServiceSuite is a test suite for the AuthService @@ -59,25 +59,25 @@ func TestAuthServiceSuite(t *testing.T) { func TestUserModelPasswordHashing(t *testing.T) { // This is a real test that tests the actual password hashing functionality // which is part of the authentication system - - user := &models.User{ + + user := &models2.User{ Username: "testuser", Email: "test@example.com", Password: "testpassword", // This will be hashed by BeforeSave Active: true, - Role: models.UserRoleReader, + Role: models2.UserRoleReader, } // Test that the CheckPassword method works correctly // We'll test with a simple approach - create a hash manually and test it - + // Test that the method exists and can be called // This is a basic test to ensure the method works assert.False(t, user.CheckPassword("wrongpassword"), "CheckPassword should return false for wrong password") - + // Test empty password assert.False(t, user.CheckPassword(""), "CheckPassword should return false for empty password") - + // Test that the method handles invalid hashes gracefully user.Password = "invalid-hash" assert.False(t, user.CheckPassword("anypassword"), "CheckPassword should return false for invalid hash") @@ -86,18 +86,18 @@ func TestUserModelPasswordHashing(t *testing.T) { // TestUserRoleValidation tests the UserRole enum func TestUserRoleValidation(t *testing.T) { // Test that all user roles are valid - validRoles := []models.UserRole{ - models.UserRoleReader, - models.UserRoleContributor, - models.UserRoleReviewer, - models.UserRoleEditor, - models.UserRoleAdmin, + validRoles := []models2.UserRole{ + models2.UserRoleReader, + models2.UserRoleContributor, + models2.UserRoleReviewer, + models2.UserRoleEditor, + models2.UserRoleAdmin, } - + for _, role := range validRoles { assert.NotEmpty(t, string(role), "User role should not be empty") } - + // Test role hierarchy (if implemented) // This would test that higher roles have more permissions than lower roles } @@ -110,7 +110,7 @@ func TestAuthErrorTypes(t *testing.T) { assert.NotNil(t, ErrUserAlreadyExists) assert.NotNil(t, ErrInvalidInput) assert.NotNil(t, ErrContextRequired) - + // Test error messages assert.Contains(t, ErrInvalidCredentials.Error(), "invalid credentials") assert.Contains(t, ErrUserNotFound.Error(), "user not found") @@ -123,9 +123,9 @@ func TestAuthErrorTypes(t *testing.T) { func TestAuthServiceInterface(t *testing.T) { // This test ensures that the AuthService interface has all required methods // It's a compile-time check that the interface is complete - + var _ AuthService = (*authService)(nil) - + // If this compiles, it means authService implements AuthService interface // This is a simple but effective way to ensure interface compliance } @@ -137,10 +137,10 @@ func TestInputStructs(t *testing.T) { Email: "test@example.com", Password: "password123", } - + assert.Equal(t, "test@example.com", loginInput.Email) assert.Equal(t, "password123", loginInput.Password) - + // Test RegisterInput struct registerInput := RegisterInput{ Username: "testuser", @@ -149,7 +149,7 @@ func TestInputStructs(t *testing.T) { FirstName: "Test", LastName: "User", } - + assert.Equal(t, "testuser", registerInput.Username) assert.Equal(t, "test@example.com", registerInput.Email) assert.Equal(t, "password123", registerInput.Password) @@ -160,20 +160,20 @@ func TestInputStructs(t *testing.T) { // TestAuthResponseStruct tests that AuthResponse struct is properly defined func TestAuthResponseStruct(t *testing.T) { // Test AuthResponse struct - user := &models.User{ + user := &models2.User{ Username: "testuser", Email: "test@example.com", Active: true, - Role: models.UserRoleReader, + Role: models2.UserRoleReader, } user.ID = 1 - + authResponse := AuthResponse{ Token: "jwt.token.here", User: user, ExpiresAt: time.Now().Add(24 * time.Hour), } - + assert.Equal(t, "jwt.token.here", authResponse.Token) assert.Equal(t, user, authResponse.User) assert.True(t, authResponse.ExpiresAt.After(time.Now())) diff --git a/services/copyright_service.go b/services/copyright_service.go index f7c4461..6f059cc 100644 --- a/services/copyright_service.go +++ b/services/copyright_service.go @@ -3,8 +3,8 @@ package services import ( "context" "errors" - "tercul/models" - "tercul/repositories" + "tercul/internal/models" + "tercul/internal/repositories" ) // CopyrightService defines business logic for copyright operations diff --git a/services/localization_service.go b/services/localization_service.go index 09dc4ac..824fdd8 100644 --- a/services/localization_service.go +++ b/services/localization_service.go @@ -3,8 +3,8 @@ package services import ( "context" "errors" - "tercul/models" - "tercul/repositories" + "tercul/internal/models" + "tercul/internal/repositories" ) // LocalizationService resolves localized attributes using translations diff --git a/services/search_index_service.go b/services/search_index_service.go index 1557be2..4a05563 100644 --- a/services/search_index_service.go +++ b/services/search_index_service.go @@ -4,9 +4,9 @@ import ( "context" "fmt" "log" - "tercul/models" - "tercul/repositories" - "tercul/weaviate" + "tercul/internal/models" + "tercul/internal/platform/search" + "tercul/internal/repositories" ) // SearchIndexService pushes localized snapshots into Weaviate for search @@ -40,7 +40,7 @@ func (s *searchIndexService) IndexWork(ctx context.Context, work models.Work) er props["content"] = content } - _, wErr := weaviate.Client.Data().Creator(). + _, wErr := search.Client.Data().Creator(). WithClassName("Work"). WithID(formatID(work.ID)). WithProperties(props). diff --git a/services/work_service.go b/services/work_service.go index 61a78f0..13bda6a 100644 --- a/services/work_service.go +++ b/services/work_service.go @@ -3,8 +3,8 @@ package services import ( "context" "errors" - "tercul/models" - "tercul/repositories" + "tercul/internal/models" + repositories2 "tercul/internal/repositories" ) // WorkService defines the interface for work-related business logic @@ -14,14 +14,14 @@ type WorkService interface { GetWorkByID(ctx context.Context, id uint) (*models.Work, error) UpdateWork(ctx context.Context, work *models.Work) error DeleteWork(ctx context.Context, id uint) error - ListWorks(ctx context.Context, page, pageSize int) (*repositories.PaginatedResult[models.Work], error) + ListWorks(ctx context.Context, page, pageSize int) (*repositories2.PaginatedResult[models.Work], error) // Domain-specific operations GetWorkWithTranslations(ctx context.Context, id uint) (*models.Work, error) FindWorksByTitle(ctx context.Context, title string) ([]models.Work, error) FindWorksByAuthor(ctx context.Context, authorID uint) ([]models.Work, error) FindWorksByCategory(ctx context.Context, categoryID uint) ([]models.Work, error) - FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*repositories.PaginatedResult[models.Work], error) + FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*repositories2.PaginatedResult[models.Work], error) // Analysis operations AnalyzeWork(ctx context.Context, workID uint) error @@ -51,14 +51,14 @@ type TranslationAnalytics struct { // workService implements WorkService type workService struct { - workRepo repositories.WorkRepository + workRepo repositories2.WorkRepository analyzer interface { AnalyzeWork(ctx context.Context, workID uint) error } } // NewWorkService creates a new WorkService -func NewWorkService(workRepo repositories.WorkRepository, analyzer interface { +func NewWorkService(workRepo repositories2.WorkRepository, analyzer interface { AnalyzeWork(ctx context.Context, workID uint) error }) WorkService { return &workService{ @@ -126,7 +126,7 @@ func (s *workService) DeleteWork(ctx context.Context, id uint) error { } // ListWorks returns a paginated list of works -func (s *workService) ListWorks(ctx context.Context, page, pageSize int) (*repositories.PaginatedResult[models.Work], error) { +func (s *workService) ListWorks(ctx context.Context, page, pageSize int) (*repositories2.PaginatedResult[models.Work], error) { return s.workRepo.List(ctx, page, pageSize) } @@ -167,7 +167,7 @@ func (s *workService) FindWorksByCategory(ctx context.Context, categoryID uint) } // FindWorksByLanguage finds works by language -func (s *workService) FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*repositories.PaginatedResult[models.Work], error) { +func (s *workService) FindWorksByLanguage(ctx context.Context, language string, page, pageSize int) (*repositories2.PaginatedResult[models.Work], error) { if language == "" { return nil, errors.New("language cannot be empty") } diff --git a/services/work_service_test.go b/services/work_service_test.go index e49674b..2a13b0c 100644 --- a/services/work_service_test.go +++ b/services/work_service_test.go @@ -2,14 +2,14 @@ package services_test import ( "context" + "tercul/internal/models" + "tercul/internal/repositories" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/suite" - "tercul/models" - "tercul/repositories" + "gorm.io/gorm" "tercul/services" - "gorm.io/gorm" ) // MockWorkRepository is a mock implementation of the WorkRepository interface @@ -18,48 +18,56 @@ type MockWorkRepository struct { } func (m *MockWorkRepository) Create(ctx context.Context, entity *models.Work) error { - args := m.Called(ctx, entity) + args := m.Called(ctx, entity) return args.Error(0) } -func (m *MockWorkRepository) CreateInTx(ctx context.Context, _ *gorm.DB, _ *models.Work) error { return nil } +func (m *MockWorkRepository) CreateInTx(ctx context.Context, _ *gorm.DB, _ *models.Work) error { + return nil +} func (m *MockWorkRepository) GetByID(ctx context.Context, id uint) (*models.Work, error) { - args := m.Called(ctx, id) + args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*models.Work), args.Error(1) } -func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, _ uint, _ *repositories.QueryOptions) (*models.Work, error) { return nil, nil } +func (m *MockWorkRepository) GetByIDWithOptions(ctx context.Context, _ uint, _ *repositories.QueryOptions) (*models.Work, error) { + return nil, nil +} func (m *MockWorkRepository) Update(ctx context.Context, entity *models.Work) error { - args := m.Called(ctx, entity) + args := m.Called(ctx, entity) return args.Error(0) } -func (m *MockWorkRepository) UpdateInTx(ctx context.Context, _ *gorm.DB, _ *models.Work) error { return nil } +func (m *MockWorkRepository) UpdateInTx(ctx context.Context, _ *gorm.DB, _ *models.Work) error { + return nil +} func (m *MockWorkRepository) Delete(ctx context.Context, id uint) error { - args := m.Called(ctx, id) + args := m.Called(ctx, id) return args.Error(0) } func (m *MockWorkRepository) DeleteInTx(ctx context.Context, _ *gorm.DB, _ uint) error { return nil } func (m *MockWorkRepository) List(ctx context.Context, page, pageSize int) (*repositories.PaginatedResult[models.Work], error) { - args := m.Called(ctx, page, pageSize) + args := m.Called(ctx, page, pageSize) if args.Get(0) == nil { return nil, args.Error(1) } return args.Get(0).(*repositories.PaginatedResult[models.Work]), args.Error(1) } -func (m *MockWorkRepository) ListWithOptions(ctx context.Context, _ *repositories.QueryOptions) ([]models.Work, error) { return nil, nil } +func (m *MockWorkRepository) ListWithOptions(ctx context.Context, _ *repositories.QueryOptions) ([]models.Work, error) { + return nil, nil +} func (m *MockWorkRepository) ListAll(ctx context.Context) ([]models.Work, error) { - args := m.Called(ctx) + args := m.Called(ctx) if args.Get(0) == nil { return nil, args.Error(1) } @@ -67,7 +75,7 @@ func (m *MockWorkRepository) ListAll(ctx context.Context) ([]models.Work, error) } func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offset int) ([]models.Work, error) { - args := m.Called(ctx, batchSize, offset) + args := m.Called(ctx, batchSize, offset) if args.Get(0) == nil { return nil, args.Error(1) } @@ -75,14 +83,16 @@ func (m *MockWorkRepository) GetAllForSync(ctx context.Context, batchSize, offse } func (m *MockWorkRepository) Count(ctx context.Context) (int64, error) { - args := m.Called(ctx) + args := m.Called(ctx) return args.Get(0).(int64), args.Error(1) } -func (m *MockWorkRepository) CountWithOptions(ctx context.Context, _ *repositories.QueryOptions) (int64, error) { return 0, nil } +func (m *MockWorkRepository) CountWithOptions(ctx context.Context, _ *repositories.QueryOptions) (int64, error) { + return 0, nil +} func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []string, id uint) (*models.Work, error) { - args := m.Called(ctx, preloads, id) + args := m.Called(ctx, preloads, id) if args.Get(0) == nil { return nil, args.Error(1) } @@ -90,11 +100,13 @@ func (m *MockWorkRepository) FindWithPreload(ctx context.Context, preloads []str } func (m *MockWorkRepository) Exists(ctx context.Context, _ uint) (bool, error) { return false, nil } -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 nil } +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 nil +} func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]models.Work, error) { - args := m.Called(ctx, title) + args := m.Called(ctx, title) if args.Get(0) == nil { return nil, args.Error(1) } @@ -102,7 +114,7 @@ func (m *MockWorkRepository) FindByTitle(ctx context.Context, title string) ([]m } func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([]models.Work, error) { - args := m.Called(ctx, authorID) + args := m.Called(ctx, authorID) if args.Get(0) == nil { return nil, args.Error(1) } @@ -110,7 +122,7 @@ func (m *MockWorkRepository) FindByAuthor(ctx context.Context, authorID uint) ([ } func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint) ([]models.Work, error) { - args := m.Called(ctx, categoryID) + args := m.Called(ctx, categoryID) if args.Get(0) == nil { return nil, args.Error(1) } @@ -118,7 +130,7 @@ func (m *MockWorkRepository) FindByCategory(ctx context.Context, categoryID uint } func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string, page, pageSize int) (*repositories.PaginatedResult[models.Work], error) { - args := m.Called(ctx, language, page, pageSize) + args := m.Called(ctx, language, page, pageSize) if args.Get(0) == nil { return nil, args.Error(1) } @@ -126,7 +138,7 @@ func (m *MockWorkRepository) FindByLanguage(ctx context.Context, language string } func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) (*models.Work, error) { - args := m.Called(ctx, id) + args := m.Called(ctx, id) if args.Get(0) == nil { return nil, args.Error(1) } @@ -134,7 +146,7 @@ func (m *MockWorkRepository) GetWithTranslations(ctx context.Context, id uint) ( } func (m *MockWorkRepository) ListWithTranslations(ctx context.Context, page, pageSize int) (*repositories.PaginatedResult[models.Work], error) { - args := m.Called(ctx, page, pageSize) + args := m.Called(ctx, page, pageSize) if args.Get(0) == nil { return nil, args.Error(1) } @@ -163,7 +175,7 @@ type WorkServiceSuite struct { func (s *WorkServiceSuite) SetupTest() { s.mockRepo = new(MockWorkRepository) s.mockAnalyzer = new(MockAnalyzer) - s.service = services.NewWorkService(s.mockRepo, s.mockAnalyzer) + s.service = services.NewWorkService(s.mockRepo, s.mockAnalyzer) } // TestCreateWork tests the CreateWork method @@ -177,16 +189,16 @@ func (s *WorkServiceSuite) TestCreateWork() { work.Language = "en" // Mock repository - s.mockRepo.On("Create", mock.Anything, work).Return(nil) + s.mockRepo.On("Create", mock.Anything, work).Return(nil) // Execute - err := s.service.CreateWork(context.Background(), work) + err := s.service.CreateWork(context.Background(), work) // Assert s.Require().NoError(err) // Verify mocks - s.mockRepo.AssertCalled(s.T(), "Create", mock.Anything, work) + s.mockRepo.AssertCalled(s.T(), "Create", mock.Anything, work) } // TestCreateWorkWithEmptyTitle tests the CreateWork method with an empty title @@ -199,7 +211,7 @@ func (s *WorkServiceSuite) TestCreateWorkWithEmptyTitle() { work.Language = "en" // Execute - err := s.service.CreateWork(context.Background(), work) + err := s.service.CreateWork(context.Background(), work) // Assert s.Require().Error(err) @@ -220,7 +232,7 @@ func (s *WorkServiceSuite) TestCreateWorkWithEmptyLanguage() { // Language is empty // Execute - err := s.service.CreateWork(context.Background(), work) + err := s.service.CreateWork(context.Background(), work) // Assert s.Require().Error(err) @@ -243,10 +255,10 @@ func (s *WorkServiceSuite) TestGetWorkByID() { expectedWork.Language = "en" // Mock repository - s.mockRepo.On("GetByID", mock.Anything, id).Return(expectedWork, nil) + s.mockRepo.On("GetByID", mock.Anything, id).Return(expectedWork, nil) // Execute - result, err := s.service.GetWorkByID(context.Background(), id) + result, err := s.service.GetWorkByID(context.Background(), id) // Assert s.Require().NoError(err) @@ -256,7 +268,7 @@ func (s *WorkServiceSuite) TestGetWorkByID() { s.Equal(expectedWork.Language, result.Language) // Verify mocks - s.mockRepo.AssertCalled(s.T(), "GetByID", mock.Anything, id) + s.mockRepo.AssertCalled(s.T(), "GetByID", mock.Anything, id) } // TestGetWorkByIDWithInvalidID tests the GetWorkByID method with an invalid ID @@ -265,7 +277,7 @@ func (s *WorkServiceSuite) TestGetWorkByIDWithInvalidID() { id := uint(0) // Invalid ID // Execute - result, err := s.service.GetWorkByID(context.Background(), id) + result, err := s.service.GetWorkByID(context.Background(), id) // Assert s.Require().Error(err) @@ -288,16 +300,16 @@ func (s *WorkServiceSuite) TestUpdateWork() { work.Language = "en" // Mock repository - s.mockRepo.On("Update", mock.Anything, work).Return(nil) + s.mockRepo.On("Update", mock.Anything, work).Return(nil) // Execute - err := s.service.UpdateWork(context.Background(), work) + err := s.service.UpdateWork(context.Background(), work) // Assert s.Require().NoError(err) // Verify mocks - s.mockRepo.AssertCalled(s.T(), "Update", mock.Anything, work) + s.mockRepo.AssertCalled(s.T(), "Update", mock.Anything, work) } // TestUpdateWorkWithInvalidID tests the UpdateWork method with an invalid ID @@ -312,7 +324,7 @@ func (s *WorkServiceSuite) TestUpdateWorkWithInvalidID() { work.Language = "en" // Execute - err := s.service.UpdateWork(context.Background(), work) + err := s.service.UpdateWork(context.Background(), work) // Assert s.Require().Error(err) @@ -328,16 +340,16 @@ func (s *WorkServiceSuite) TestDeleteWork() { id := uint(1) // Mock repository - s.mockRepo.On("Delete", mock.Anything, id).Return(nil) + s.mockRepo.On("Delete", mock.Anything, id).Return(nil) // Execute - err := s.service.DeleteWork(context.Background(), id) + err := s.service.DeleteWork(context.Background(), id) // Assert s.Require().NoError(err) // Verify mocks - s.mockRepo.AssertCalled(s.T(), "Delete", mock.Anything, id) + s.mockRepo.AssertCalled(s.T(), "Delete", mock.Anything, id) } // TestDeleteWorkWithInvalidID tests the DeleteWork method with an invalid ID @@ -346,7 +358,7 @@ func (s *WorkServiceSuite) TestDeleteWorkWithInvalidID() { id := uint(0) // Invalid ID // Execute - err := s.service.DeleteWork(context.Background(), id) + err := s.service.DeleteWork(context.Background(), id) // Assert s.Require().Error(err) diff --git a/syncjob/batch_processor.go b/syncjob/batch_processor.go index 0d02593..5ec0dad 100644 --- a/syncjob/batch_processor.go +++ b/syncjob/batch_processor.go @@ -3,11 +3,11 @@ package syncjob import ( "context" "fmt" + "gorm.io/gorm" "log" "strings" - "tercul/config" - "tercul/weaviate" - "gorm.io/gorm" + "tercul/internal/platform/config" + "tercul/internal/platform/search" ) // BatchProcessor handles batch processing of entities for sync operations @@ -22,7 +22,7 @@ func NewBatchProcessor(db *gorm.DB) *BatchProcessor { if batchSize <= 0 { batchSize = DefaultBatchSize } - + return &BatchProcessor{ db: db, defaultBatchSize: batchSize, @@ -32,44 +32,44 @@ func NewBatchProcessor(db *gorm.DB) *BatchProcessor { // ProcessEntitiesBatch processes a batch of entities from a given table func (bp *BatchProcessor) ProcessEntitiesBatch(ctx context.Context, className string, batchSize, offset int) error { log.Printf("Processing %s batch (offset %d, batch size %d)...", className, offset, batchSize) - + var results []map[string]interface{} tableName := toSnakeCase(className) - + if err := bp.db.Table(tableName).Limit(batchSize).Offset(offset).Find(&results).Error; err != nil { return fmt.Errorf("error fetching %s batch: %w", className, err) } - + if len(results) == 0 { log.Printf("No %s records found for batch (offset %d)", className, offset) return nil } - + return bp.CreateObjectsBatch(ctx, className, results) } // ProcessAllEntities processes all entities of a given type in batches func (bp *BatchProcessor) ProcessAllEntities(ctx context.Context, className string) error { log.Printf("Processing all %s entities...", className) - + var count int64 tableName := toSnakeCase(className) if err := bp.db.Table(tableName).Count(&count).Error; err != nil { return fmt.Errorf("error counting %s: %w", className, err) } - + if count == 0 { log.Printf("No %s entities found", className) return nil } - + for offset := 0; offset < int(count); offset += bp.defaultBatchSize { if err := bp.ProcessEntitiesBatch(ctx, className, bp.defaultBatchSize, offset); err != nil { log.Printf("Error processing %s batch (offset %d): %v", className, offset, err) // Continue with next batch instead of failing completely } } - + log.Printf("Completed processing all %s entities", className) return nil } @@ -105,11 +105,11 @@ func toSnakeCase(str string) string { "HybridEntityWork": "hybrid_entity_work", "CopyrightClaim": "copyright_claim", } - + if tableName, exists := specialCases[str]; exists { return tableName } - + // For other cases, convert CamelCase to snake_case var result string for i, r := range str { @@ -124,7 +124,7 @@ func toSnakeCase(str string) string { // CreateObjectsBatch creates multiple objects in Weaviate using the existing client func (bp *BatchProcessor) CreateObjectsBatch(ctx context.Context, className string, objects []map[string]interface{}) error { var errors []error - + for _, record := range objects { objID := fmt.Sprintf("%v", record["id"]) if err := bp.createObject(ctx, className, objID, record); err != nil { @@ -132,26 +132,26 @@ func (bp *BatchProcessor) CreateObjectsBatch(ctx context.Context, className stri errors = append(errors, err) } } - + if len(errors) > 0 { return fmt.Errorf("batch sync completed with %d errors", len(errors)) } - + return nil } // createObject creates a single object in Weaviate using the existing client func (bp *BatchProcessor) createObject(ctx context.Context, className, objID string, properties map[string]interface{}) error { - _, err := weaviate.Client.Data().Creator(). + _, err := search.Client.Data().Creator(). WithClassName(className). WithID(objID). WithProperties(properties). Do(ctx) - + if err != nil { return fmt.Errorf("failed to create %s object with ID %s: %w", className, objID, err) } - + log.Printf("Successfully synced %s ID %s", className, objID) return nil } diff --git a/syncjob/edges_sync.go b/syncjob/edges_sync.go index 7e8490d..3d116f7 100644 --- a/syncjob/edges_sync.go +++ b/syncjob/edges_sync.go @@ -4,7 +4,7 @@ import ( "context" "fmt" "log" - "tercul/models" + "tercul/internal/models" ) // SyncAllEdges syncs all edges by enqueueing batch jobs. diff --git a/syncjob/entities_sync.go b/syncjob/entities_sync.go index 8ad7445..abe48d8 100644 --- a/syncjob/entities_sync.go +++ b/syncjob/entities_sync.go @@ -79,5 +79,3 @@ func (s *SyncJob) syncEntities(className string, ctx context.Context) error { batchProcessor := NewBatchProcessor(s.DB) return batchProcessor.ProcessAllEntities(ctx, className) } - - diff --git a/syncjob/queue.go b/syncjob/queue.go index 953c320..575584e 100644 --- a/syncjob/queue.go +++ b/syncjob/queue.go @@ -14,13 +14,13 @@ func enqueueTask[T any](client *asynq.Client, taskType string, payload T, delay if err != nil { return err } - + task := asynq.NewTask(taskType, data) _, err = client.Enqueue(task, asynq.ProcessIn(delay)) if err != nil { return err } - + return nil } diff --git a/syncjob/types.go b/syncjob/types.go index 0cf7f23..4b8d74e 100644 --- a/syncjob/types.go +++ b/syncjob/types.go @@ -32,9 +32,3 @@ type EdgeSyncPayload struct { BatchSize int `json:"batch_size"` Offset int `json:"offset"` } - - - - - -